feat: Phase 15 birthdays - user birth dates and calendar birthday template
- AppUser.BirthDate (optional): admin edits in user management, every user edits own via PUT /api/auth/me/birth-date; validator (not before 1900, not future); BirthdayRules (age, leap day 29.2. -> 28.2.) - Privacy: directory BirthDate only for self, admin and family; audit log without the value - Calendar template "Geburtstag": CalendarEvent.BirthDate, all-day yearly series enforced server-side, age and display title per occurrence, ICS/CalDAV export as single series with category, BirthDate kept on CalDAV PUT - Migration Phase15Birthdays, tests (unit, bUnit, integration), docs (ROADMAP, ARCHITECTURE, ADR-0015 addendum) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ce15cb32d
commit
ab505d4a4f
38 files changed
+4160
-45
No files matched your search
@@ -80,12 +80,12 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
|
||||
|
||||
| Bereich | Entitäten |
|
||||
|---|---|
|
||||
| Identity ✅ | `AppUser` (DisplayName, Color, AvatarUrl, MustChangePassword, IsActive, ExpiresAt, PinHash, LastLoginAt, Soft Delete), `AppRole` (Admin, Familie, Kind, Gast), `UserAvatar` (Bild ≤ 1 MB) |
|
||||
| Identity ✅ | `AppUser` (DisplayName, Color, AvatarUrl, BirthDate (optional, `DateOnly`, Phase 15), 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, LearningPhotoRetentionDays, AiChatEnabled, AiChatChildrenAllowed) |
|
||||
| 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, Note, RecipeUrl, Ingredients, UseCount = Planungen, WishCount, WishedBy, LastPlannedAt, Soft Delete; eindeutiger NormalizedName – dauerhafte Wunschliste und Autovervollständigung, `/api/meals/wishlist`), `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), `CalDavCredential` (UserId, Label, SecretHash, SecretPrefix, LastUsedAt, RevokedAt; App-Passwort für CalDAV) – ADR-0015/0016/0017 |
|
||||
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, BirthDate (Geburtstagsvorlage, Phase 15), 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), `CalDavCredential` (UserId, Label, SecretHash, SecretPrefix, LastUsedAt, RevokedAt; App-Passwort für CalDAV) – ADR-0015/0016/0017 |
|
||||
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, AllowWebSearch, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt), `AiExchange` ✅ (12b: KI-Protokoll je Aufruf – Person, Anbieter, Modell, Zweck, Beginn, Dauer, Erfolg/Fehler, Bildanzahl, Online-Suche, Tokens, `ContentStored`, Prompt, Antwort; Prompt (inkl. System-Prompt) und Antwort immer, auch im KI-Chat und bei Fehlern (Phase 14); `AiUserSettings` ✅ (Phase 14: Admin-Vorgaben je Person – Anbieter, Modell, System-Prompt, Prompt deaktiviert); Aufbewahrung `FamilySettings.AiLogRetentionDays`, Standard 90) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
|
||||
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
|
||||
| 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 (Paper je Aufgabe oder für das ganze Blatt in einem Schritt, 12a), 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); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
|
||||
@@ -146,10 +146,10 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
|
||||
|
||||
| Bereich | Endpunkte |
|
||||
|---|---|
|
||||
| Auth ✅ | `POST /api/auth/login`, `POST /api/auth/pin-login` (Feature-Flag), `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/change-password` |
|
||||
| Auth ✅ | `POST /api/auth/login`, `POST /api/auth/pin-login` (Feature-Flag), `POST /api/auth/logout`, `GET /api/auth/me`, `PUT /api/auth/me/birth-date` (eigenes Geburtsdatum, jeder angemeldete Benutzer), `POST /api/auth/change-password` |
|
||||
| Admin – Benutzer ✅ | `GET/POST /api/admin/users`, `GET/PUT/DELETE /api/admin/users/{id}`, `POST …/{id}/reset-password`, `POST …/{id}/unlock`, `PUT …/{id}/pin`, `PUT/DELETE …/{id}/avatar` |
|
||||
| Admin – Einstellungen ✅ | `GET /api/settings` (alle), `PUT /api/admin/settings`, `GET /api/admin/audit?page=&pageSize=` |
|
||||
| Benutzerverzeichnis ✅ | `GET /api/users`, `GET /api/users/{id}/avatar` |
|
||||
| Benutzerverzeichnis ✅ | `GET /api/users`, `GET /api/users/{id}/avatar` – `BirthDate` im Verzeichnis nur für die eigene Person, Admins und Familie (`BirthdayRules.CanView`, serverseitig) |
|
||||
| Einkauf ✅ | `GET/POST /api/shopping/lists`, `PUT/DELETE /api/shopping/lists/{id}`, `GET/POST /api/shopping/lists/{id}/items`, `PATCH/DELETE /api/shopping/items/{id}`, `POST /api/shopping/lists/{id}/clear-done`, `GET /api/shopping/suggestions?q=&take=` |
|
||||
| Essen ✅ | `GET /api/meals?from=&to=`, `PUT /api/meals` (Upsert je Tag + Mahlzeit, Platz im Body), `DELETE /api/meals/{id}`, `POST /api/meals/{id}/move`, `POST /api/meals/{id}/to-shopping`, `GET /api/meals/dishes?q=&take=`, `GET/POST /api/meals/wishes`, `POST …/wishes/{id}/accept`, `POST …/wishes/{id}/reject`, `DELETE …/wishes/{id}` |
|
||||
| Aufgaben ✅ | `GET/POST /api/tasks` (Anlegen idempotent mit Client-Id), `GET/PUT/DELETE /api/tasks/{id}` (Detail inkl. Verlauf, Ändern mit Version), `POST /api/tasks/{id}/status` (Zielstatus), `PUT /api/tasks/{id}/checklist/{itemId}`, `POST /api/tasks/{id}/approve`, `POST /api/tasks/{id}/reject`, `GET /api/tasks/points`, `POST /api/tasks/points/adjust` |
|
||||
|
||||
+7
-1
@@ -194,6 +194,12 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- [x] KI-Entwurf: Admin sieht den gesendeten Prompt (`POST /api/learning/sets/ai-draft/prompt`), kann Systemanweisung und Nutzer-Prompt anpassen (`CreateAiDraftRequest.PromptOverride`, nur Admin, serverseitig 403 sonst), Schema bleibt separat, Vermerk am Blatt (`AiPrompt`)
|
||||
- [x] Migration `Phase14AiPrompts`; Tests: Unit (Auflösung, Prompt-Override, Validatoren), bUnit (Entwurf-Dialog Admin/Eltern, Benutzer-KI-Dialog), Integration (`AiPromptTests`, `AiExchangeLogTests`)
|
||||
|
||||
## Phase 15 – Geburtstage
|
||||
- [x] Geburtsdatum (optional) für alle Benutzer: Admin setzt es in der Benutzerverwaltung (Spalte mit Alter), jeder Benutzer im Profil („Meine Einstellungen“, `PUT /api/auth/me/birth-date`); Validator (nicht vor 1900, nicht in der Zukunft); Alter als reine Funktion `BirthdayRules.AgeOn` (Schaltjahr 29.2. → 28.2.)
|
||||
- [x] Datenschutz: `BirthDate` im Benutzerverzeichnis nur für die eigene Person, Admins und Familie, serverseitig; Audit-Log ohne Wert
|
||||
- [x] Kalender-Vorlage „Geburtstag“: Person aus dem Verzeichnis (Datum vorbelegt) oder freie Eingabe; ganztägig, jährlich, Titel „Geburtstag von X“, Alter je Vorkommen (`CalendarEvent.BirthDate`, `Age`/`DisplayTitle`) in Agenda/Monat/Woche/Dashboard; ICS/CalDAV: eine Serie mit Kategorie „Geburtstag“, `BirthDate` bleibt beim CalDAV-`PUT` erhalten (ADR-0015 Nachtrag)
|
||||
- [x] Migration `Phase15Birthdays`; Tests: Unit (Alter/Schaltjahr, Validatoren, Normalisierung, DTO, ICS), bUnit (Vorlage, Benutzerverwaltung, Profil), Integration (`BirthdayTests`, CalDAV-Round-Trip)
|
||||
|
||||
## Backlog
|
||||
- PIN-Login-Oberfläche für ein Familien-Tablet (Profilauswahl + Ziffernblock) – Api ist vorbereitet
|
||||
- Selbst-Upload des eigenen Profilbilds, Profilseite für Anzeigename/Farbe
|
||||
@@ -205,7 +211,7 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- Chat: Nachrichten bearbeiten, Antworten, Suche, Aufräumen alter Nachrichten/Bilder
|
||||
- Kalender: Stunden-Raster mit Drag & Drop, „dieser und folgende“, Erinnerungen per Web-Push für Termine
|
||||
- Rezeptdatenbank
|
||||
- Geburtstage und Jahrestage
|
||||
- Jahrestage; automatische Geburtstage aller Benutzer als eigene Kalenderebene
|
||||
- Wand-Tablet-Modus (Kiosk-Dashboard)
|
||||
- Outlook-Synchronisation über Microsoft Graph (ADR-0017): Zwei-Wege-Abgleich mit Outlook/Microsoft 365 (OAuth, Delta-Abfragen, Webhooks); Outlook kann kein CalDAV, bis dahin nur lesend per ICS-Abo
|
||||
- CalDAV: Praxistest mit echten Clients (iOS/macOS, Thunderbird, DAVx5), VALARM/Teilnehmer erhalten, Admin-Übersicht und Widerruf der App-Passwörter aller Benutzer
|
||||
@@ -43,3 +43,21 @@ Zeitraum neu. `Guid.Empty` = Kalenderliste/Rechte geändert.
|
||||
## Konsequenzen
|
||||
* Eine Datenquelle für App, Dashboard (`/api/calendar/upcoming`) und ICS-Feeds (ADR-0016).
|
||||
* Importierte Regeln außerhalb der Teilmenge werden nicht expandiert (siehe ADR-0016).
|
||||
|
||||
## Nachtrag Phase 15 – Geburtstage
|
||||
* **Geburtsdatum:** `AppUser.BirthDate` (optional, `DateOnly`). Setzen: Admin in der Benutzerverwaltung (`UserManagement`), jeder Benutzer für sich selbst
|
||||
(`PUT /api/auth/me/birth-date`, auch Kinder und Gäste – es gibt keine fremdbestimmten Profilregeln, die das verbieten; Eltern ohne `UserManagement` pflegen
|
||||
das Datum eines Kindes nicht). Validator in `Shared` (nicht vor 1900, nicht in der Zukunft, Stichtag heute in Europe/Berlin über `TimeProvider`).
|
||||
Das Audit-Log vermerkt nur „Geburtsdatum geändert“, nicht den Wert.
|
||||
* **Datenschutz:** `GET /api/users` liefert `BirthDate` nur für die eigene Person, Admins und Familie (Eltern); Kinder und Gäste sehen fremde Geburtsdaten nie.
|
||||
Ein Termin trägt sein Geburtsdatum für alle, die den Kalender lesen dürfen – im DTO aber nur für Benutzer, die den Termin ändern dürfen (das Alter je
|
||||
Vorkommen sehen alle Leser).
|
||||
* **Alter:** reine Funktion `BirthdayRules.AgeOn`. Geburtstage am 29.2. werden in Nichtschaltjahren am **28.2.** gefeiert (Serienregel
|
||||
`FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1`, passend zum Alter: ab dem 28.2. gilt das neue Lebensjahr; rechtlich nach § 187/188 BGB wäre es der 1.3.).
|
||||
* **Vorlage „Geburtstag“:** `CalendarEvent.BirthDate` markiert den Termin als Geburtstagsserie. Der Server erzwingt (`CalendarEndpoints.NormalizeBirthday`):
|
||||
ganztägig, jährlich, Beginn am ersten Geburtstag (Geburtsjahr + 1); gesendete Zeiten/Regeln werden überschrieben. Titel bleibt schlicht „Geburtstag von Anna“;
|
||||
`CalendarOccurrenceDto.Age`/`DisplayTitle` liefern je Vorkommen „… (7 Jahre)“. Geburtstagsserien werden nur als Ganzes bearbeitet (kein „nur dieses Vorkommen“);
|
||||
Löschen einzelner Jahre geht nur über die API (`scope=Occurrence`); in der Oberfläche löscht „Löschen“ die ganze Serie (offener Punkt); ein geändertes Geburtsdatum verwirft Ausnahmen/Überschreibungen. Überschreibungen verwenden das Datum der Serie.
|
||||
* **Export:** ICS-Abo und CalDAV liefern genau eine Serie (`SUMMARY` ohne Alter – ein VEVENT kann keinen Titel je Vorkommen tragen) mit `CATEGORIES:Geburtstag`;
|
||||
das Geburtsdatum wird nicht exportiert. CalDAV-Clients kennen `BirthDate` nicht: Beim `PUT` bleibt es erhalten (wie Ersteller und Personen-Tags).
|
||||
* **Nicht umgesetzt:** automatische Geburtstage aller Benutzer im Kalender (wäre eine zusätzliche virtuelle Ebene je Sichtbarkeitsregel; bewusst weggelassen).
|
||||
@@ -74,6 +74,7 @@ public static class UserAdminEndpoints
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
MustChangePassword = true,
|
||||
IsActive = true,
|
||||
BirthDate = request.BirthDate,
|
||||
CreatedAt = now,
|
||||
};
|
||||
|
||||
@@ -122,6 +123,12 @@ public static class UserAdminEndpoints
|
||||
Track(changes, "Rolle", currentRole, request.Role);
|
||||
Track(changes, "Aktiv", user.IsActive.ToString(), request.IsActive.ToString());
|
||||
Track(changes, "Ablaufdatum", user.ExpiresAt?.ToString("u"), request.ExpiresAt?.ToString("u"));
|
||||
if (user.BirthDate != request.BirthDate)
|
||||
{
|
||||
// Personenbezogen: im Audit-Log nur der Vermerk, nicht der Wert.
|
||||
changes.Add("Geburtsdatum geändert");
|
||||
}
|
||||
|
||||
|
||||
var securityRelevant = currentRole != request.Role || user.IsActive != request.IsActive || user.ExpiresAt != request.ExpiresAt;
|
||||
|
||||
@@ -130,6 +137,7 @@ public static class UserAdminEndpoints
|
||||
user.Color = request.Color.ToUpperInvariant();
|
||||
user.IsActive = request.IsActive;
|
||||
user.ExpiresAt = request.ExpiresAt;
|
||||
user.BirthDate = request.BirthDate;
|
||||
|
||||
var result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
@@ -367,7 +375,8 @@ public static class UserAdminEndpoints
|
||||
user.MustChangePassword,
|
||||
user.PinHash is not null,
|
||||
user.CreatedAt,
|
||||
user.LastLoginAt);
|
||||
user.LastLoginAt,
|
||||
user.BirthDate);
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ public static class AuthEndpoints
|
||||
group.MapGet("/me", MeAsync)
|
||||
.RequireAuthorization();
|
||||
|
||||
// Jeder angemeldete Benutzer (auch Kinder und Gäste) pflegt sein eigenes Geburtsdatum.
|
||||
group.MapPut("/me/birth-date", SetBirthDateAsync)
|
||||
.RequireAuthorization()
|
||||
.Validate<SetBirthDateRequest>();
|
||||
|
||||
group.MapPost("/change-password", ChangePasswordAsync)
|
||||
.RequireAuthorization()
|
||||
.Validate<ChangePasswordRequest>();
|
||||
@@ -141,6 +146,23 @@ public static class AuthEndpoints
|
||||
return TypedResults.Ok(await userManager.ToCurrentUserDtoAsync(user, settingsProvider));
|
||||
}
|
||||
|
||||
internal static async Task<Results<Ok<CurrentUserDto>, UnauthorizedHttpResult>> SetBirthDateAsync(
|
||||
SetBirthDateRequest request,
|
||||
HttpContext httpContext,
|
||||
UserManager<AppUser> userManager,
|
||||
ISettingsProvider settingsProvider)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(httpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
user.BirthDate = request.BirthDate;
|
||||
await userManager.UpdateAsync(user);
|
||||
return TypedResults.Ok(await userManager.ToCurrentUserDtoAsync(user, settingsProvider));
|
||||
}
|
||||
|
||||
internal static async Task<Results<Ok<CurrentUserDto>, ValidationProblem, UnauthorizedHttpResult>> ChangePasswordAsync(
|
||||
ChangePasswordRequest request,
|
||||
HttpContext httpContext,
|
||||
|
||||
@@ -6,6 +6,7 @@ using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Domain.Recurrence;
|
||||
using FamilyPlanner.Domain.Tasks;
|
||||
using FamilyPlanner.Domain.Users;
|
||||
using FamilyPlanner.Infrastructure.Audit;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Infrastructure.Settings;
|
||||
@@ -158,6 +159,7 @@ public static class CalendarEndpoints
|
||||
return TypedResults.Forbid();
|
||||
}
|
||||
|
||||
request = NormalizeBirthday(request);
|
||||
var zone = CalendarTime.Zone((await settingsProvider.GetAsync(ct)).TimeZoneId);
|
||||
var userId = user.UserId() ?? Guid.Empty;
|
||||
|
||||
@@ -185,6 +187,7 @@ public static class CalendarEndpoints
|
||||
};
|
||||
ApplyContent(calendarEvent, request);
|
||||
calendarEvent.RecurrenceRule = NormalizeRule(request.RecurrenceRule);
|
||||
calendarEvent.BirthDate = request.BirthDate;
|
||||
db.CalendarEvents.Add(calendarEvent);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await notifier.ChangedAsync(calendarEvent.CalendarId, ct);
|
||||
@@ -240,8 +243,10 @@ public static class CalendarEndpoints
|
||||
}
|
||||
|
||||
var zone = CalendarTime.Zone((await settingsProvider.GetAsync(ct)).TimeZoneId);
|
||||
request = NormalizeBirthday(request);
|
||||
var (startUtc, endUtc) = CalendarTime.ToUtc(Span(request), zone);
|
||||
var onlyOccurrence = (scope ?? EventEditScope.All) == EventEditScope.Occurrence && (target.IsOverride || target.IsRecurring);
|
||||
var onlyOccurrence = (scope ?? EventEditScope.All) == EventEditScope.Occurrence && (target.IsOverride || target.IsRecurring)
|
||||
&& master.BirthDate is null && request.BirthDate is null; // Geburtstagsserien nur als Ganzes ändern
|
||||
|
||||
if (onlyOccurrence)
|
||||
{
|
||||
@@ -290,7 +295,7 @@ public static class CalendarEndpoints
|
||||
|
||||
// Serie über ein Vorkommen bearbeitet: Verschiebung relativ zum Vorkommen auf den Serienbeginn übertragen.
|
||||
var reference = target.IsOverride ? target.RecurrenceId : master.IsRecurring ? occurrence : null;
|
||||
if (reference is { } occurrenceStart && master.IsRecurring)
|
||||
if (reference is { } occurrenceStart && master.IsRecurring && request.BirthDate is null)
|
||||
{
|
||||
var shift = FamilyTime.ToLocal(startUtc, zone) - FamilyTime.ToLocal(occurrenceStart, zone);
|
||||
var newStart = FamilyTime.ToUtc(FamilyTime.ToLocal(master.StartUtc, zone) + shift, zone);
|
||||
@@ -301,20 +306,27 @@ public static class CalendarEndpoints
|
||||
var oldCalendar = master.CalendarId;
|
||||
ApplyContent(master, request);
|
||||
(master.StartUtc, master.EndUtc, master.CalendarId) = (startUtc, endUtc, request.CalendarId);
|
||||
var birthDateChanged = master.BirthDate != request.BirthDate && request.BirthDate is not null;
|
||||
master.BirthDate = request.BirthDate;
|
||||
master.RecurrenceRule = NormalizeRule(request.RecurrenceRule);
|
||||
master.Sequence++;
|
||||
if (master.RecurrenceRule is null)
|
||||
{
|
||||
master.ExceptionDates = [];
|
||||
}
|
||||
else if (birthDateChanged)
|
||||
{
|
||||
// Neuer Serienbeginn: bisherige Ausnahmen und Einzeländerungen passen nicht mehr.
|
||||
master.ExceptionDates = [];
|
||||
}
|
||||
|
||||
if (oldCalendar != master.CalendarId || master.RecurrenceRule is null)
|
||||
if (oldCalendar != master.CalendarId || master.RecurrenceRule is null || birthDateChanged)
|
||||
{
|
||||
// Überschreibungen folgen dem Kalender; ohne Wiederholung entfallen sie.
|
||||
var overrides = await db.CalendarEvents.Where(e => e.SeriesMasterId == master.Id).ToListAsync(ct);
|
||||
foreach (var o in overrides)
|
||||
{
|
||||
if (master.RecurrenceRule is null)
|
||||
if (master.RecurrenceRule is null || birthDateChanged)
|
||||
{
|
||||
db.CalendarEvents.Remove(o);
|
||||
}
|
||||
@@ -517,6 +529,26 @@ public static class CalendarEndpoints
|
||||
e.ForWholeFamily = r.ForWholeFamily;
|
||||
}
|
||||
|
||||
/// <summary>Geburtstagsvorlage: ganztägig, jährlich, Beginn am ersten Geburtstag – unabhängig von den gesendeten Zeiten.</summary>
|
||||
internal static SaveEventRequest NormalizeBirthday(SaveEventRequest r)
|
||||
{
|
||||
if (r.BirthDate is not { } birthDate)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
|
||||
var first = BirthdayRules.FirstBirthday(birthDate);
|
||||
return r with
|
||||
{
|
||||
IsAllDay = true,
|
||||
StartDate = first,
|
||||
EndDate = first,
|
||||
StartTime = null,
|
||||
EndTime = null,
|
||||
RecurrenceRule = BirthdayRules.RecurrenceRuleFor(birthDate),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeRule(string? rule) =>
|
||||
RecurrenceRule.TryParse(rule, out var parsed, out _) ? parsed.ToString() : null;
|
||||
|
||||
@@ -544,12 +576,17 @@ public static class CalendarEndpoints
|
||||
var e = o.Event;
|
||||
var master = e.IsOverride ? byId.GetValueOrDefault(e.SeriesMasterId!.Value) : e;
|
||||
var local = CalendarTime.ToLocal(o.StartUtc, o.EndUtc, e.IsAllDay, zone);
|
||||
var birthDate = master?.BirthDate ?? e.BirthDate;
|
||||
var canEdit = CalendarRules.CanModify(access, master?.CreatedBy ?? e.CreatedBy, userId);
|
||||
int? age = birthDate is { } birth ? BirthdayRules.AgeOn(birth, FamilyTime.LocalDate(o.OccurrenceKey, zone)) : null;
|
||||
return new CalendarOccurrenceDto(
|
||||
e.Id, o.SeriesId, o.OccurrenceKey, e.CalendarId, e.Title, e.Location, e.Notes, e.IsAllDay,
|
||||
o.StartUtc, o.EndUtc, local.StartDate, local.StartTime, local.EndDate, local.EndTime,
|
||||
e.PersonIds, e.ForWholeFamily, master?.RecurrenceRule, e.IsRecurring, e.IsOverride,
|
||||
master?.CreatedBy ?? e.CreatedBy,
|
||||
CalendarRules.CanModify(access, master?.CreatedBy ?? e.CreatedBy, userId),
|
||||
e.RowVersion);
|
||||
canEdit,
|
||||
e.RowVersion,
|
||||
canEdit ? birthDate : null,
|
||||
age);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ public static class IcsBuilder
|
||||
{
|
||||
public const string ProductId = "-//FamilyPlanner//Familienplaner//DE";
|
||||
|
||||
/// <summary>CATEGORIES-Wert für Geburtstagsserien.</summary>
|
||||
public const string BirthdayCategory = "Geburtstag";
|
||||
|
||||
public static string Build(
|
||||
string feedName,
|
||||
string timeZoneId,
|
||||
@@ -102,6 +105,12 @@ public static class IcsBuilder
|
||||
ical.Categories = [calendarName];
|
||||
}
|
||||
|
||||
if (e.BirthDate is not null)
|
||||
{
|
||||
// Der Titel je Vorkommen kann das Alter nicht tragen (eine Serie hat einen SUMMARY) – Kategorie markiert die Serie als Geburtstag.
|
||||
ical.Categories.Add(BirthdayCategory);
|
||||
}
|
||||
|
||||
if (e.IsRecurring)
|
||||
{
|
||||
ical.RecurrenceRule = new RecurrencePattern(e.RecurrenceRule!);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
using FamilyPlanner.Domain.Users;
|
||||
using FamilyPlanner.Infrastructure.Identity;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
@@ -19,18 +22,25 @@ public static class UserDirectoryEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static async Task<Ok<List<UserDirectoryEntry>>> ListAsync(AppDbContext db, TimeProvider timeProvider, CancellationToken ct)
|
||||
internal static async Task<Ok<List<UserDirectoryEntry>>> ListAsync(
|
||||
ClaimsPrincipal principal, AppDbContext db, TimeProvider timeProvider, CancellationToken ct)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var roles = await db.GetRoleMapAsync(ct);
|
||||
var users = await db.Users.AsNoTracking()
|
||||
.Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now))
|
||||
.OrderBy(u => u.DisplayName)
|
||||
.Select(u => new { u.Id, u.DisplayName, u.Color, u.AvatarUrl })
|
||||
.Select(u => new { u.Id, u.DisplayName, u.Color, u.AvatarUrl, u.BirthDate })
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Geburtsdatum nur für die eigene Person, Admins und Familie – serverseitig, nicht im UI.
|
||||
var viewerId = principal.UserId() ?? Guid.Empty;
|
||||
var viewerRoles = principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
|
||||
|
||||
return TypedResults.Ok(users
|
||||
.Select(u => new UserDirectoryEntry(u.Id, u.DisplayName, u.Color, u.AvatarUrl, roles.GetValueOrDefault(u.Id, string.Empty)))
|
||||
.Select(u => new UserDirectoryEntry(
|
||||
u.Id, u.DisplayName, u.Color, u.AvatarUrl, roles.GetValueOrDefault(u.Id, string.Empty),
|
||||
BirthdayRules.CanView(viewerId, viewerRoles, u.Id) ? u.BirthDate : null))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ internal static class EndpointHelpers
|
||||
user.MustChangePassword,
|
||||
user.AvatarUrl,
|
||||
user.ExpiresAt,
|
||||
PermissionMatrix.Effective(roles, settings.ToPermissionOptions()));
|
||||
PermissionMatrix.Effective(roles, settings.ToPermissionOptions()),
|
||||
user.BirthDate);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,12 @@ public sealed class CalendarEvent : AuditableEntity, ISoftDeletable, IHasRowVers
|
||||
/// <summary>Herkunft beim Import (Dateiname), sonst <c>null</c>.</summary>
|
||||
public string? ImportSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gesetzt = Geburtstagstermin (Vorlage „Geburtstag von …“): ganztägig, jährlich wiederkehrend, Alter je Vorkommen aus diesem Datum.
|
||||
/// Nur an der Serie (bzw. dem Einzeltermin) gepflegt; Überschreibungen verwenden das Datum der Serie.
|
||||
/// </summary>
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace FamilyPlanner.Domain.Users;
|
||||
|
||||
/// <summary>
|
||||
/// Reine Regeln rund um Geburtstage: Gültigkeit, Alter, Geburtstag je Jahr und die Vorlage „Geburtstag von …“ im Kalender.
|
||||
/// Geburtstage vom 29.2. werden in Nichtschaltjahren am 28.2. gefeiert (passend zur Wiederholungsregel
|
||||
/// <c>BYMONTH=2;BYMONTHDAY=-1</c>); ab diesem Tag zählt das neue Lebensjahr.
|
||||
/// </summary>
|
||||
public static class BirthdayRules
|
||||
{
|
||||
public const int MinYear = 1900;
|
||||
|
||||
public const string TitlePrefix = "Geburtstag von ";
|
||||
|
||||
/// <summary>Nicht in der Zukunft und nicht vor 1900.</summary>
|
||||
public static bool IsValidBirthDate(DateOnly birthDate, DateOnly today) => birthDate.Year >= MinYear && birthDate <= today;
|
||||
|
||||
/// <summary>Der Tag, an dem im angegebenen Jahr Geburtstag gefeiert wird (29.2. → 28.2. in Nichtschaltjahren).</summary>
|
||||
public static DateOnly BirthdayIn(DateOnly birthDate, int year) =>
|
||||
birthDate is { Month: 2, Day: 29 } && !DateTime.IsLeapYear(year)
|
||||
? new DateOnly(year, 2, 28)
|
||||
: new DateOnly(year, birthDate.Month, birthDate.Day);
|
||||
|
||||
/// <summary>Alter in vollendeten Jahren am Stichtag; <c>null</c>, wenn der Stichtag vor der Geburt liegt.</summary>
|
||||
public static int? AgeOn(DateOnly birthDate, DateOnly date)
|
||||
{
|
||||
if (date < birthDate)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var age = date.Year - birthDate.Year;
|
||||
return date < BirthdayIn(birthDate, date.Year) ? age - 1 : age;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Datenschutz: Das Geburtsdatum sieht nur die Person selbst sowie Admins und Familie (Eltern) – Kinder und Gäste sehen fremde Geburtsdaten nicht.
|
||||
/// </summary>
|
||||
public static bool CanView(Guid viewerId, IEnumerable<string> viewerRoles, Guid personId) =>
|
||||
viewerId == personId || viewerRoles.Any(r => r is Roles.Admin or Roles.Family);
|
||||
|
||||
/// <summary>Erster Geburtstag (Beginn der Kalenderserie).</summary>
|
||||
public static DateOnly FirstBirthday(DateOnly birthDate) => BirthdayIn(birthDate, birthDate.Year + 1);
|
||||
|
||||
/// <summary>Wiederholungsregel der jährlichen Geburtstagsserie (RFC 5545, Teilmenge laut ADR-0014).</summary>
|
||||
public static string RecurrenceRuleFor(DateOnly birthDate) =>
|
||||
birthDate is { Month: 2, Day: 29 } ? "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1" : "FREQ=YEARLY";
|
||||
|
||||
public static string TitleFor(string name) => TitlePrefix + name.Trim();
|
||||
|
||||
/// <summary>Name aus einem Titel „Geburtstag von X“; sonst der Titel unverändert.</summary>
|
||||
public static string NameFromTitle(string title) =>
|
||||
title.StartsWith(TitlePrefix, StringComparison.Ordinal) ? title[TitlePrefix.Length..].Trim() : title;
|
||||
|
||||
/// <summary>„7 Jahre“, „1 Jahr“.</summary>
|
||||
public static string AgeLabel(int age) => age == 1 ? "1 Jahr" : $"{age} Jahre";
|
||||
|
||||
/// <summary>Titel je Vorkommen, z. B. „Geburtstag von Anna (7 Jahre)“; ohne Alter der Titel unverändert.</summary>
|
||||
public static string DisplayTitle(string title, int? age) => age is { } a ? $"{title} ({AgeLabel(a)})" : title;
|
||||
}
|
||||
@@ -30,6 +30,9 @@ public sealed class AppUser : IdentityUser<Guid>, ISoftDeletable
|
||||
/// <summary>Optionales Ablaufdatum (für Gastzugänge).</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>Optionales Geburtsdatum (personenbezogen: nur für die Person selbst, Admins und Eltern sichtbar).</summary>
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
|
||||
Generated
+2820
File diff suppressed because it is too large.
Load diff
+39
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase15Birthdays : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "BirthDate",
|
||||
table: "CalendarEvents",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "BirthDate",
|
||||
table: "AspNetUsers",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BirthDate",
|
||||
table: "CalendarEvents");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BirthDate",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateOnly?>("BirthDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("CalendarId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
@@ -2381,6 +2384,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateOnly?>("BirthDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(9)
|
||||
|
||||
@@ -15,7 +15,8 @@ public sealed record UserAdminDto(
|
||||
bool MustChangePassword,
|
||||
bool HasPin,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastLoginAt);
|
||||
DateTimeOffset? LastLoginAt,
|
||||
DateOnly? BirthDate = null);
|
||||
|
||||
public sealed record CreateUserRequest(
|
||||
string UserName,
|
||||
@@ -24,7 +25,8 @@ public sealed record CreateUserRequest(
|
||||
string Role,
|
||||
string Color,
|
||||
string Password,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateOnly? BirthDate = null);
|
||||
|
||||
public sealed record UpdateUserRequest(
|
||||
string DisplayName,
|
||||
@@ -32,15 +34,19 @@ public sealed record UpdateUserRequest(
|
||||
string Role,
|
||||
string Color,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateOnly? BirthDate = null);
|
||||
|
||||
public sealed record ResetPasswordRequest(string NewPassword);
|
||||
|
||||
/// <summary><c>Pin = null</c> entfernt die PIN.</summary>
|
||||
public sealed record SetPinRequest(string? Pin);
|
||||
|
||||
/// <summary>Öffentliches Verzeichnis der Familienmitglieder (für Zuweisungen, Farben, Avatare).</summary>
|
||||
public sealed record UserDirectoryEntry(Guid Id, string DisplayName, string Color, string? AvatarUrl, string Role);
|
||||
/// <summary>
|
||||
/// Öffentliches Verzeichnis der Familienmitglieder (für Zuweisungen, Farben, Avatare). <see cref="BirthDate"/> ist personenbezogen und wird
|
||||
/// serverseitig nur für die eigene Person, Admins und Familie (Eltern) befüllt – sonst <c>null</c>.
|
||||
/// </summary>
|
||||
public sealed record UserDirectoryEntry(Guid Id, string DisplayName, string Color, string? AvatarUrl, string Role, DateOnly? BirthDate = null);
|
||||
|
||||
public sealed record FamilySettingsDto(
|
||||
string FamilyName,
|
||||
|
||||
@@ -33,7 +33,7 @@ internal static class AdminRules
|
||||
|
||||
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
|
||||
{
|
||||
public CreateUserRequestValidator()
|
||||
public CreateUserRequestValidator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
RuleFor(r => r.UserName)
|
||||
.NotEmpty().WithMessage("Bitte einen Benutzernamen eingeben.")
|
||||
@@ -43,6 +43,7 @@ public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserReq
|
||||
RuleFor(r => r.Email).ValidOptionalEmail().When(r => !string.IsNullOrWhiteSpace(r.Email));
|
||||
RuleFor(r => r.Role).ValidRole();
|
||||
RuleFor(r => r.Color).ValidColor();
|
||||
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
|
||||
RuleFor(r => r.Password).ValidNewPassword();
|
||||
RuleFor(r => r.ExpiresAt).Null().When(r => r.Role != Roles.Guest)
|
||||
.WithMessage("Ein Ablaufdatum ist nur für Gastzugänge vorgesehen.");
|
||||
@@ -51,12 +52,13 @@ public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserReq
|
||||
|
||||
public sealed class UpdateUserRequestValidator : AbstractValidator<UpdateUserRequest>
|
||||
{
|
||||
public UpdateUserRequestValidator()
|
||||
public UpdateUserRequestValidator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
RuleFor(r => r.DisplayName).ValidDisplayName();
|
||||
RuleFor(r => r.Email).ValidOptionalEmail().When(r => !string.IsNullOrWhiteSpace(r.Email));
|
||||
RuleFor(r => r.Role).ValidRole();
|
||||
RuleFor(r => r.Color).ValidColor();
|
||||
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
|
||||
RuleFor(r => r.ExpiresAt).Null().When(r => r.Role != Roles.Guest)
|
||||
.WithMessage("Ein Ablaufdatum ist nur für Gastzugänge vorgesehen.");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ public sealed record PinLoginRequest(string UserName, string Pin);
|
||||
|
||||
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
|
||||
/// <summary>Eigenes Geburtsdatum setzen; <c>null</c> entfernt es.</summary>
|
||||
public sealed record SetBirthDateRequest(DateOnly? BirthDate);
|
||||
|
||||
public sealed record CurrentUserDto(
|
||||
Guid Id,
|
||||
string UserName,
|
||||
@@ -19,10 +22,19 @@ public sealed record CurrentUserDto(
|
||||
bool MustChangePassword,
|
||||
string? AvatarUrl = null,
|
||||
DateTimeOffset? ExpiresAt = null,
|
||||
IReadOnlyDictionary<Permission, AccessLevel>? Permissions = null)
|
||||
IReadOnlyDictionary<Permission, AccessLevel>? Permissions = null,
|
||||
DateOnly? BirthDate = null)
|
||||
{
|
||||
public AccessLevel AccessTo(Permission permission) =>
|
||||
Permissions is not null && Permissions.TryGetValue(permission, out var level) ? level : AccessLevel.None;
|
||||
|
||||
public bool Can(Permission permission) => AccessTo(permission) != AccessLevel.None;
|
||||
}
|
||||
|
||||
public sealed class SetBirthDateRequestValidator : FluentValidation.AbstractValidator<SetBirthDateRequest>
|
||||
{
|
||||
public SetBirthDateRequestValidator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Domain.Users;
|
||||
using FluentValidation;
|
||||
|
||||
namespace FamilyPlanner.Shared;
|
||||
|
||||
internal static class BirthDateValidation
|
||||
{
|
||||
public const string Message = "Bitte ein Geburtsdatum zwischen 1900 und heute wählen (nicht in der Zukunft).";
|
||||
|
||||
/// <summary>Optionales Geburtsdatum: nicht vor 1900, nicht in der Zukunft (Stichtag: heute in der Familienzeitzone).</summary>
|
||||
public static IRuleBuilderOptions<T, DateOnly?> ValidOptionalBirthDate<T>(this IRuleBuilder<T, DateOnly?> rule, TimeProvider? timeProvider) =>
|
||||
rule.Must(date => date is null || BirthdayRules.IsValidBirthDate(date.Value, FamilyTime.Today(timeProvider ?? TimeProvider.System)))
|
||||
.WithMessage(Message);
|
||||
}
|
||||
@@ -40,7 +40,8 @@ public sealed record SaveEventRequest(
|
||||
IReadOnlyList<Guid> PersonIds,
|
||||
bool ForWholeFamily,
|
||||
string? RecurrenceRule,
|
||||
byte[]? Version = null);
|
||||
byte[]? Version = null,
|
||||
DateOnly? BirthDate = null);
|
||||
|
||||
/// <summary>Ein Vorkommen im angefragten Zeitraum (Einzeltermin, Serienvorkommen oder geänderter Einzeltermin einer Serie).</summary>
|
||||
public sealed record CalendarOccurrenceDto(
|
||||
@@ -65,8 +66,13 @@ public sealed record CalendarOccurrenceDto(
|
||||
bool IsOverride,
|
||||
Guid? CreatedBy,
|
||||
bool CanEdit,
|
||||
byte[] Version)
|
||||
byte[] Version,
|
||||
DateOnly? BirthDate = null,
|
||||
int? Age = null)
|
||||
{
|
||||
/// <summary>Titel für die Anzeige: bei Geburtstagen mit Alter je Vorkommen („Geburtstag von Anna (7 Jahre)“).</summary>
|
||||
public string DisplayTitle => BirthdayRules.DisplayTitle(Title, Age);
|
||||
|
||||
/// <summary>Teil einer Serie (Vorkommen oder Überschreibung)?</summary>
|
||||
public bool IsPartOfSeries => IsRecurring || IsOverride;
|
||||
|
||||
@@ -143,8 +149,9 @@ public sealed class SaveEventRequestValidator : AbstractValidator<SaveEventReque
|
||||
public const int MaxPersons = 20;
|
||||
public const int MaxDurationDays = 366;
|
||||
|
||||
public SaveEventRequestValidator()
|
||||
public SaveEventRequestValidator(TimeProvider? timeProvider = null)
|
||||
{
|
||||
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
|
||||
RuleFor(r => r.CalendarId).NotEmpty().WithMessage("Bitte einen Kalender wählen.");
|
||||
RuleFor(r => r.Title).NotEmpty().WithMessage("Bitte einen Titel eingeben.")
|
||||
.MaximumLength(CalendarEvent.TitleMaxLength).WithMessage($"Der Titel darf höchstens {CalendarEvent.TitleMaxLength} Zeichen haben.");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@inject IAdminApi AdminApi
|
||||
@inject ISnackbar Snackbar
|
||||
@inject TimeProvider TimeProvider
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@@ -31,6 +32,11 @@
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudDatePicker @bind-Date="_model.BirthDate" Label="Geburtsdatum (optional)" Variant="Variant.Outlined" Clearable="true"
|
||||
DateFormat="dd.MM.yyyy" InputId="birthdate" Class="fp-birthdate"
|
||||
HelperText="@BirthDateHelp"
|
||||
Error="@HasError("BirthDate")" ErrorText="@ErrorFor("BirthDate")" />
|
||||
|
||||
<div>
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Farbe</MudText>
|
||||
<div class="d-flex flex-wrap gap-2 align-center" role="radiogroup" aria-label="Farbe">
|
||||
@@ -107,6 +113,10 @@
|
||||
|
||||
private bool IsEdit => User is not null;
|
||||
|
||||
private string? BirthDateHelp => _model.BirthDate is { } birth && BirthdayRules.AgeOn(DateOnly.FromDateTime(birth), FamilyTime.Today(TimeProvider)) is { } age
|
||||
? $"Alter: {BirthdayRules.AgeLabel(age)}"
|
||||
: "Nur für die Person selbst, Eltern und Admins sichtbar.";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (User is { } user)
|
||||
@@ -117,6 +127,7 @@
|
||||
_model.Role = user.Role;
|
||||
_model.Color = user.Color;
|
||||
_model.IsActive = user.IsActive;
|
||||
_model.BirthDate = user.BirthDate?.ToDateTime(TimeOnly.MinValue);
|
||||
_model.ExpiresOn = user.ExpiresAt is { } e ? FamilyTime.LocalDate(e).ToDateTime(TimeOnly.MinValue) : null;
|
||||
_avatarUrl = user.AvatarUrl;
|
||||
}
|
||||
@@ -125,6 +136,7 @@
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_generalError = null;
|
||||
var birthDate = _model.BirthDate is { } birth ? DateOnly.FromDateTime(birth) : (DateOnly?)null;
|
||||
var expiresAt = _model.Role == Roles.Guest && _model.ExpiresOn is { } date
|
||||
? FamilyTime.EndOfDayUtc(DateOnly.FromDateTime(date))
|
||||
: (DateTimeOffset?)null;
|
||||
@@ -133,8 +145,8 @@
|
||||
UserAdminDto? saved;
|
||||
if (User is null)
|
||||
{
|
||||
var request = new CreateUserRequest(_model.UserName.Trim(), _model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.Password, expiresAt);
|
||||
if (!ValidateLocally(new CreateUserRequestValidator().Validate(request)))
|
||||
var request = new CreateUserRequest(_model.UserName.Trim(), _model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.Password, expiresAt, birthDate);
|
||||
if (!ValidateLocally(new CreateUserRequestValidator(TimeProvider).Validate(request)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -145,8 +157,8 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
var request = new UpdateUserRequest(_model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.IsActive, expiresAt);
|
||||
if (!ValidateLocally(new UpdateUserRequestValidator().Validate(request)))
|
||||
var request = new UpdateUserRequest(_model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.IsActive, expiresAt, birthDate);
|
||||
if (!ValidateLocally(new UpdateUserRequestValidator(TimeProvider).Validate(request)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -256,5 +268,7 @@
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime? ExpiresOn { get; set; }
|
||||
|
||||
public DateTime? BirthDate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@using FamilyPlanner.Domain.Users
|
||||
@inject IAdminApi AdminApi
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject TimeProvider TimeProvider
|
||||
|
||||
<div class="d-flex align-center flex-wrap gap-2 mb-3">
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2">Benutzer</MudText>
|
||||
@@ -24,6 +26,7 @@ else
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Rolle</MudTh>
|
||||
<MudTh>Geburtstag</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh>Letzte Anmeldung</MudTh>
|
||||
<MudTh><span class="sr-only">Aktionen</span></MudTh>
|
||||
@@ -41,6 +44,7 @@ else
|
||||
<MudTd DataLabel="Rolle">
|
||||
<MudChip T="string" Size="Size.Small" Color="RoleColor(context.Role)">@Roles.DisplayName(context.Role)</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Geburtstag" Class="fp-user-birthdate">@BirthDateText(context.BirthDate)</MudTd>
|
||||
<MudTd DataLabel="Status">
|
||||
@foreach (var (text, color) in StatusChips(context))
|
||||
{
|
||||
@@ -227,6 +231,12 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>„05.03.2018 (7 Jahre)“ bzw. „–“.</summary>
|
||||
private string BirthDateText(DateOnly? birthDate) =>
|
||||
birthDate is { } birth
|
||||
? $"{birth:dd.MM.yyyy}" + (BirthdayRules.AgeOn(birth, FamilyPlanner.Domain.Common.FamilyTime.Today(TimeProvider)) is { } age ? $" ({BirthdayRules.AgeLabel(age)})" : string.Empty)
|
||||
: "–";
|
||||
|
||||
internal static string FormatDate(DateTimeOffset? value) =>
|
||||
value is { } v ? FamilyPlanner.Domain.Common.FamilyTime.ToFamilyTime(v).ToString("dd.MM.yyyy HH:mm") : "–";
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public static class CalendarViewModel
|
||||
}
|
||||
|
||||
items.Add(new CalendarItem(
|
||||
$"{o.EventId}:{o.OccurrenceKey.UtcTicks}", o.Title, o.StartDate, o.EndDate, o.StartTime, o.EndTime, o.IsAllDay,
|
||||
$"{o.EventId}:{o.OccurrenceKey.UtcTicks}", o.DisplayTitle, o.StartDate, o.EndDate, o.StartTime, o.EndTime, o.IsAllDay,
|
||||
ColorFor(o, calendarMap, userMap), Persons(o.PersonIds, userMap), o.ForWholeFamily, o.Location, o, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@using System.Globalization
|
||||
@using FamilyPlanner.Domain.Calendar
|
||||
@using FamilyPlanner.Domain.Recurrence
|
||||
@using FamilyPlanner.Domain.Users
|
||||
@using FamilyPlanner.Shared.Admin
|
||||
@using FamilyPlanner.Shared.Calendar
|
||||
@inject ICalendarApi Api
|
||||
@inject IDialogService DialogService
|
||||
@@ -19,6 +21,32 @@
|
||||
<MudAlert Severity="Severity.Info" Dense="true">Du änderst nur diesen einen Termin der Serie.</MudAlert>
|
||||
}
|
||||
|
||||
@if (Occurrence is null && !ReadOnly)
|
||||
{
|
||||
<MudSelect T="string" Value="_template" ValueChanged="SetTemplate" Label="Vorlage" Variant="Variant.Outlined" Class="fp-event-template">
|
||||
<MudSelectItem T="string" Value="@string.Empty">Keine Vorlage</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@BirthdayTemplate">Geburtstag</MudSelectItem>
|
||||
</MudSelect>
|
||||
}
|
||||
|
||||
@if (IsBirthday)
|
||||
{
|
||||
<MudSelect T="Guid?" Value="_birthPersonId" ValueChanged="SelectBirthdayPerson" Label="Person aus der Familie" Variant="Variant.Outlined"
|
||||
Clearable="true" Class="fp-birthday-person"
|
||||
HelperText="Nur Personen mit hinterlegtem Geburtsdatum – oder unten Name und Datum frei eingeben.">
|
||||
@foreach (var person in UsersWithBirthDate)
|
||||
{
|
||||
<MudSelectItem T="Guid?" Value="@((Guid?)person.Id)">@person.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_birthName" Label="Name" InputId="birthday-name" Variant="Variant.Outlined"
|
||||
MaxLength="@(CalendarEvent.TitleMaxLength - BirthdayRules.TitlePrefix.Length)" />
|
||||
<MudTextField @bind-Value="_birthDate" Label="Geburtsdatum" InputType="InputType.Date" InputId="birthday-date"
|
||||
Variant="Variant.Outlined" ShrinkLabel="true"
|
||||
HelperText="Der Termin ist ganztägig und wiederholt sich jedes Jahr; das Alter wird je Jahr berechnet." />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_model.Title" Label="Titel" InputId="event-title" Variant="Variant.Outlined" AutoFocus="@(!ReadOnly)"
|
||||
ReadOnly="ReadOnly" MaxLength="CalendarEvent.TitleMaxLength" />
|
||||
|
||||
@@ -40,6 +68,7 @@
|
||||
Variant="Variant.Outlined" ReadOnly="ReadOnly" ShrinkLabel="true" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<MudSelect T="Guid" @bind-Value="_model.CalendarId" Label="Kalender" Variant="Variant.Outlined"
|
||||
Disabled="@(ReadOnly || Scope == EventEditScope.Occurrence)">
|
||||
@@ -70,12 +99,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!IsBirthday)
|
||||
{
|
||||
<MudTextField @bind-Value="_model.Location" Label="Ort (optional)" InputId="event-location" Variant="Variant.Outlined"
|
||||
ReadOnly="ReadOnly" MaxLength="CalendarEvent.LocationMaxLength" />
|
||||
}
|
||||
<MudTextField @bind-Value="_model.Notes" Label="Notiz (optional)" InputId="event-notes" Variant="Variant.Outlined"
|
||||
ReadOnly="ReadOnly" Lines="2" Sizing="InputSizing.Auto" MaxLength="CalendarEvent.NotesMaxLength" />
|
||||
|
||||
@if (Scope == EventEditScope.All)
|
||||
@if (Scope == EventEditScope.All && !IsBirthday)
|
||||
{
|
||||
<RecurrenceEditor @bind-Model="_model.Recurrence" StartDate="ParsedStartDate" ReadOnly="ReadOnly" />
|
||||
}
|
||||
@@ -123,20 +155,38 @@
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
private const string BirthdayTemplate = "birthday";
|
||||
|
||||
private readonly EventFormModel _model = new();
|
||||
private string _template = string.Empty;
|
||||
private Guid? _birthPersonId;
|
||||
private string _birthName = string.Empty;
|
||||
private string _birthDate = string.Empty;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
private IEnumerable<CalendarDto> SelectableCalendars =>
|
||||
Calendars.Where(c => c.Access >= CalendarAccess.WriteOwn || c.Id == _model.CalendarId);
|
||||
|
||||
private bool IsBirthday => _template == BirthdayTemplate;
|
||||
|
||||
// Geburtsdaten liefert der Server nur für die eigene Person, Admins und Familie (Datenschutz) – Kinder und Gäste können frei eingeben.
|
||||
private IEnumerable<UserDirectoryEntry> UsersWithBirthDate => Users.Where(u => u.BirthDate is not null);
|
||||
|
||||
private DateOnly ParsedStartDate => ParseDate(_model.StartDate) ?? DefaultDate;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Occurrence is { } o)
|
||||
{
|
||||
_model.Title = o.Title;
|
||||
_model.Title = ReadOnly ? o.DisplayTitle : o.Title;
|
||||
if (o.BirthDate is { } birth)
|
||||
{
|
||||
_template = BirthdayTemplate;
|
||||
_birthName = BirthdayRules.NameFromTitle(o.Title);
|
||||
_birthDate = Format(birth);
|
||||
}
|
||||
|
||||
_model.IsAllDay = o.IsAllDay;
|
||||
_model.StartDate = Format(o.StartDate);
|
||||
_model.EndDate = Format(o.EndDate);
|
||||
@@ -164,6 +214,25 @@
|
||||
|
||||
private void SetAllDay(bool value) => _model.IsAllDay = value;
|
||||
|
||||
private void SetTemplate(string value)
|
||||
{
|
||||
_template = value;
|
||||
if (IsBirthday && _model.PersonIds.Count == 0)
|
||||
{
|
||||
_model.ForWholeFamily = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectBirthdayPerson(Guid? id)
|
||||
{
|
||||
_birthPersonId = id;
|
||||
if (id is { } personId && Users.FirstOrDefault(u => u.Id == personId) is { BirthDate: { } birth } person)
|
||||
{
|
||||
_birthName = person.DisplayName;
|
||||
_birthDate = Format(birth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Beginn verschoben: Ende behält den Abstand.</summary>
|
||||
private void SetStartDate(string value)
|
||||
{
|
||||
@@ -200,14 +269,46 @@
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (ParseDate(_model.StartDate) is not { } startDate || ParseDate(_model.EndDate) is not { } endDate)
|
||||
SaveEventRequest request;
|
||||
if (IsBirthday)
|
||||
{
|
||||
_error = "Bitte Beginn und Ende angeben.";
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(_birthName))
|
||||
{
|
||||
_error = "Bitte einen Namen eingeben.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (ParseDate(_birthDate) is not { } birthDate)
|
||||
{
|
||||
_error = "Bitte das Geburtsdatum angeben.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Ganztägig, jährlich, Beginn am ersten Geburtstag – der Server erzwingt dasselbe.
|
||||
var first = BirthdayRules.FirstBirthday(birthDate);
|
||||
request = new SaveEventRequest(
|
||||
Occurrence is null ? Guid.CreateVersion7() : null, _model.CalendarId, BirthdayRules.TitleFor(_birthName),
|
||||
null, _model.Notes, true, first, null, first, null, _model.PersonIds, _model.ForWholeFamily,
|
||||
BirthdayRules.RecurrenceRuleFor(birthDate), Occurrence?.Version, birthDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ParseDate(_model.StartDate) is not { } startDate || ParseDate(_model.EndDate) is not { } endDate)
|
||||
{
|
||||
_error = "Bitte Beginn und Ende angeben.";
|
||||
return;
|
||||
}
|
||||
|
||||
request = BuildRequest(startDate, endDate);
|
||||
}
|
||||
|
||||
await SubmitAsync(request);
|
||||
}
|
||||
|
||||
private SaveEventRequest BuildRequest(DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
var rule = Scope == EventEditScope.All ? _model.Recurrence.ToRule(startDate) : null;
|
||||
var request = new SaveEventRequest(
|
||||
return new SaveEventRequest(
|
||||
Occurrence is null ? Guid.CreateVersion7() : null,
|
||||
_model.CalendarId,
|
||||
_model.Title.Trim(),
|
||||
@@ -222,7 +323,10 @@
|
||||
_model.ForWholeFamily,
|
||||
rule,
|
||||
Occurrence?.Version);
|
||||
}
|
||||
|
||||
private async Task SubmitAsync(SaveEventRequest request)
|
||||
{
|
||||
var validation = new SaveEventRequestValidator().Validate(request);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
@page "/konto/einstellungen"
|
||||
@using FamilyPlanner.Domain.Common
|
||||
@using FamilyPlanner.Domain.Users
|
||||
@using FamilyPlanner.Shared.Auth
|
||||
@inject ThemeState Theme
|
||||
@inject IProfileApi ProfileApi
|
||||
@inject ISnackbar Snackbar
|
||||
@inject TimeProvider TimeProvider
|
||||
|
||||
<PageTitle>Meine Einstellungen – Familienplaner</PageTitle>
|
||||
|
||||
@@ -15,6 +21,20 @@
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
|
||||
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Geburtsdatum</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
Optional. Sichtbar nur für dich, die Eltern und Administratoren – Gäste und andere Kinder sehen es nicht.
|
||||
</MudText>
|
||||
<div class="d-flex align-center flex-wrap gap-2 fp-profile-birthdate">
|
||||
<MudDatePicker @bind-Date="_birthDate" Label="Geburtsdatum" Variant="Variant.Outlined" Clearable="true" DateFormat="dd.MM.yyyy"
|
||||
InputId="profile-birthdate" Style="max-width:240px" HelperText="@AgeText"
|
||||
Error="@(_birthError is not null)" ErrorText="@_birthError" />
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SaveBirthDateAsync" Disabled="_savingBirthDate"
|
||||
Class="fp-profile-birthdate-save">Speichern</MudButton>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@@ -31,5 +51,42 @@
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private DateTime? _birthDate;
|
||||
private string? _birthError;
|
||||
private bool _savingBirthDate;
|
||||
|
||||
private string? AgeText => _birthDate is { } birth && BirthdayRules.AgeOn(DateOnly.FromDateTime(birth), FamilyTime.Today(TimeProvider)) is { } age
|
||||
? $"Alter: {BirthdayRules.AgeLabel(age)}"
|
||||
: null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var me = await ProfileApi.GetMeAsync();
|
||||
_birthDate = me.Value?.BirthDate?.ToDateTime(TimeOnly.MinValue);
|
||||
}
|
||||
|
||||
private async Task SaveBirthDateAsync()
|
||||
{
|
||||
_birthError = null;
|
||||
var birth = _birthDate is { } value ? DateOnly.FromDateTime(value) : (DateOnly?)null;
|
||||
var validation = new SetBirthDateRequestValidator(TimeProvider).Validate(new SetBirthDateRequest(birth));
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
_birthError = validation.Errors[0].ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
_savingBirthDate = true;
|
||||
var result = await ProfileApi.SetBirthDateAsync(birth);
|
||||
_savingBirthDate = false;
|
||||
if (result.Problem is not null)
|
||||
{
|
||||
_birthError = result.Problem.Message;
|
||||
return;
|
||||
}
|
||||
|
||||
Snackbar.Add(birth is null ? "Geburtsdatum entfernt." : "Geburtsdatum gespeichert.", Severity.Success);
|
||||
}
|
||||
|
||||
private Task OnThemeChangedAsync(ThemePreference preference) => Theme.SetPreferenceAsync(preference);
|
||||
}
|
||||
@@ -297,7 +297,7 @@ else
|
||||
|
||||
var occurrence = item.Occurrence!;
|
||||
var scope = EventEditScope.All;
|
||||
if (occurrence.CanEdit && occurrence.IsPartOfSeries)
|
||||
if (occurrence.CanEdit && occurrence.IsPartOfSeries && occurrence.BirthDate is null)
|
||||
{
|
||||
var choice = await DialogService.ShowMessageBoxAsync(
|
||||
"Serientermin bearbeiten",
|
||||
|
||||
@@ -95,7 +95,7 @@ else
|
||||
{
|
||||
<li>
|
||||
<span class="fp-dash-label">@DashboardFormat.EventTime(occurrence, day.Date)</span>
|
||||
<span class="fp-dash-main">@occurrence.Title</span>
|
||||
<span class="fp-dash-main">@occurrence.DisplayTitle</span>
|
||||
@if (!string.IsNullOrWhiteSpace(occurrence.Location))
|
||||
{
|
||||
<span class="fp-dash-sub">@occurrence.Location</span>
|
||||
|
||||
@@ -32,6 +32,7 @@ builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().Cre
|
||||
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAdminApi, AdminApi>();
|
||||
builder.Services.AddScoped<IProfileApi, ProfileApi>();
|
||||
builder.Services.AddScoped<IShoppingApi, ShoppingApi>();
|
||||
builder.Services.AddScoped<IMealsApi, MealsApi>();
|
||||
builder.Services.AddScoped<ITasksApi, TasksApi>();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
|
||||
namespace FamilyPlanner.Web.Client.Services;
|
||||
|
||||
/// <summary>Eigene Profildaten (derzeit: Geburtsdatum).</summary>
|
||||
public interface IProfileApi
|
||||
{
|
||||
Task<ApiResult<CurrentUserDto>> GetMeAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary><c>null</c> entfernt das Geburtsdatum.</summary>
|
||||
Task<ApiResult<CurrentUserDto>> SetBirthDateAsync(DateOnly? birthDate, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class ProfileApi(HttpClient http) : IProfileApi
|
||||
{
|
||||
public Task<ApiResult<CurrentUserDto>> GetMeAsync(CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<CurrentUserDto>(() => http.GetAsync("api/auth/me", ct), ct);
|
||||
|
||||
public Task<ApiResult<CurrentUserDto>> SetBirthDateAsync(DateOnly? birthDate, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<CurrentUserDto>(() => http.PutAsJsonAsync("api/auth/me/birth-date", new SetBirthDateRequest(birthDate), ct), ct);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using FamilyPlanner.Shared.Calendar;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>Phase 15: Geburtsdatum der Benutzer (Rechte, Datenschutz) und Kalender-Vorlage „Geburtstag“.</summary>
|
||||
public sealed class BirthdayTests(AppFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static readonly DateOnly Born = new(2018, 3, 5);
|
||||
|
||||
private static async Task<Guid> FamilyCalendarIdAsync(HttpClient client) =>
|
||||
(await client.GetFromJsonAsync<List<CalendarDto>>("/api/calendars", Ct))!.Single(c => c.Name == "Familie").Id;
|
||||
|
||||
private static async Task<CalendarRangeDto> RangeAsync(HttpClient client, DateOnly from, DateOnly to) =>
|
||||
(await client.GetFromJsonAsync<CalendarRangeDto>($"/api/calendar/events?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}&includeTasks=false", Ct))!;
|
||||
|
||||
/// <summary>Vorkommen einer Serie in ganzen Jahren (die Abfrage ist auf eine begrenzte Spanne beschränkt).</summary>
|
||||
private static async Task<List<CalendarOccurrenceDto>> SeriesAsync(HttpClient client, Guid seriesId, params int[] years)
|
||||
{
|
||||
var result = new List<CalendarOccurrenceDto>();
|
||||
foreach (var year in years)
|
||||
{
|
||||
result.AddRange((await RangeAsync(client, new DateOnly(year, 1, 1), new DateOnly(year, 12, 31))).Occurrences.Where(o => o.SeriesId == seriesId));
|
||||
}
|
||||
|
||||
return result.OrderBy(o => o.StartDate).ToList();
|
||||
}
|
||||
|
||||
private static async Task<UserDirectoryEntry?> DirectoryEntryAsync(HttpClient client, Guid id) =>
|
||||
(await client.GetFromJsonAsync<List<UserDirectoryEntry>>("/api/users", Ct))!.SingleOrDefault(u => u.Id == id);
|
||||
|
||||
private static string Key(CalendarOccurrenceDto o) => Uri.EscapeDataString(o.OccurrenceKey.ToString("o"));
|
||||
|
||||
/// <summary>Bewusst mit abweichenden Zeiten/Regel: Der Server leitet alles aus dem Geburtsdatum ab.</summary>
|
||||
private static SaveEventRequest Birthday(Guid calendarId, string name, DateOnly birth) =>
|
||||
new(Guid.NewGuid(), calendarId, $"Geburtstag von {name}", null, null,
|
||||
false, new DateOnly(2040, 1, 1), new TimeOnly(9, 0), new DateOnly(2040, 1, 2), new TimeOnly(10, 0),
|
||||
[], true, "FREQ=DAILY", null, birth);
|
||||
|
||||
// ---------- Benutzer ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_sets_and_changes_the_birth_date_with_audit_note_but_without_the_value()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var userName = $"geb-{Guid.NewGuid():N}"[..20];
|
||||
var created = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest(userName, "Geburtstagskind", null, Roles.Child, "#43A047", AppFixture.StartPassword, null, Born), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
var user = (await created.Content.ReadFromJsonAsync<UserAdminDto>(Ct))!;
|
||||
Assert.Equal(Born, user.BirthDate);
|
||||
|
||||
var update = await admin.PutAsJsonAsync($"/api/admin/users/{user.Id}",
|
||||
new UpdateUserRequest("Geburtstagskind", null, Roles.Child, "#43A047", true, null, new DateOnly(2017, 12, 24)), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||
Assert.Equal(new DateOnly(2017, 12, 24), (await admin.GetFromJsonAsync<UserAdminDto>($"/api/admin/users/{user.Id}", Ct))!.BirthDate);
|
||||
|
||||
var audit = await admin.GetFromJsonAsync<PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=100", Ct);
|
||||
var entry = audit!.Items.First(e => e.TargetName == "Geburtstagskind" && e.Action == "user.updated");
|
||||
Assert.Contains("Geburtsdatum geändert", entry.Details);
|
||||
Assert.DoesNotContain("2017", entry.Details);
|
||||
Assert.DoesNotContain("24.12", entry.Details);
|
||||
|
||||
var cleared = await admin.PutAsJsonAsync($"/api/admin/users/{user.Id}",
|
||||
new UpdateUserRequest("Geburtstagskind", null, Roles.Child, "#43A047", true, null, null), Ct);
|
||||
Assert.Null((await cleared.Content.ReadFromJsonAsync<UserAdminDto>(Ct))!.BirthDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_birth_dates_are_rejected_for_admin_and_self_service()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var future = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30);
|
||||
var tooOld = new DateOnly(1899, 12, 31);
|
||||
|
||||
foreach (var bad in new[] { future, tooOld })
|
||||
{
|
||||
var create = await admin.PostAsJsonAsync("/api/admin/users",
|
||||
new CreateUserRequest($"bad-{Guid.NewGuid():N}"[..20], "Ungültig", null, Roles.Family, "#43A047", AppFixture.StartPassword, null, bad), Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
|
||||
}
|
||||
|
||||
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await kid.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(future), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await kid.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(tooOld), Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Family)]
|
||||
[InlineData(Roles.Child)]
|
||||
[InlineData(Roles.Guest)]
|
||||
public async Task Every_role_maintains_their_own_birth_date(string role)
|
||||
{
|
||||
var (user, client) = await fixture.CreateSignedInUserAsync(role, Ct);
|
||||
|
||||
var set = await client.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(Born), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, set.StatusCode);
|
||||
Assert.Equal(Born, (await set.Content.ReadFromJsonAsync<CurrentUserDto>(Ct))!.BirthDate);
|
||||
Assert.Equal(Born, (await client.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct))!.BirthDate);
|
||||
Assert.Equal(Born, (await DirectoryEntryAsync(client, user.Id))!.BirthDate);
|
||||
|
||||
var cleared = await client.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(null), Ct);
|
||||
Assert.Null((await cleared.Content.ReadFromJsonAsync<CurrentUserDto>(Ct))!.BirthDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_service_requires_login()
|
||||
{
|
||||
using var anonymous = fixture.CreateApiClient();
|
||||
|
||||
var response = await anonymous.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(Born), Ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Directory_exposes_birth_dates_only_to_self_admin_and_family()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (kidUser, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, otherKid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, (await kid.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(Born), Ct)).StatusCode);
|
||||
|
||||
Assert.Equal(Born, (await DirectoryEntryAsync(kid, kidUser.Id))!.BirthDate);
|
||||
Assert.Equal(Born, (await DirectoryEntryAsync(admin, kidUser.Id))!.BirthDate);
|
||||
Assert.Equal(Born, (await DirectoryEntryAsync(family, kidUser.Id))!.BirthDate);
|
||||
Assert.Null((await DirectoryEntryAsync(otherKid, kidUser.Id))!.BirthDate);
|
||||
Assert.Null((await DirectoryEntryAsync(guest, kidUser.Id))!.BirthDate);
|
||||
}
|
||||
|
||||
// ---------- Kalender-Vorlage ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Birthday_template_creates_an_all_day_yearly_series_with_age_per_occurrence()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
var name = $"Anna{Guid.NewGuid():N}"[..12];
|
||||
|
||||
var created = await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
var first = (await created.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
|
||||
Assert.True(first.IsAllDay);
|
||||
Assert.Equal(new DateOnly(2019, 3, 5), first.StartDate);
|
||||
Assert.Equal("FREQ=YEARLY", first.RecurrenceRule);
|
||||
Assert.Equal((Born, 1), (first.BirthDate, first.Age));
|
||||
|
||||
var occurrences = await SeriesAsync(admin, first.SeriesId, 2030, 2031, 2032);
|
||||
Assert.Equal([new DateOnly(2030, 3, 5), new DateOnly(2031, 3, 5), new DateOnly(2032, 3, 5)], occurrences.Select(o => o.StartDate));
|
||||
Assert.Equal([12, 13, 14], occurrences.Select(o => o.Age!.Value));
|
||||
Assert.Equal($"Geburtstag von {name} (13 Jahre)", occurrences[1].DisplayTitle);
|
||||
Assert.All(occurrences, o => Assert.Equal($"Geburtstag von {name}", o.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leap_day_birthday_is_celebrated_on_feb_28_in_common_years()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
var name = $"Schalt{Guid.NewGuid():N}"[..12];
|
||||
|
||||
var created = (await (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, new DateOnly(2004, 2, 29)), Ct))
|
||||
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
|
||||
|
||||
var occurrences = await SeriesAsync(admin, created.SeriesId, 2031, 2032);
|
||||
Assert.Equal([(new DateOnly(2031, 2, 28), 27), (new DateOnly(2032, 2, 29), 28)], occurrences.Select(o => (o.StartDate, o.Age!.Value)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editing_the_birth_date_moves_the_series_and_drops_stale_exceptions()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
var name = $"Ben{Guid.NewGuid():N}"[..12];
|
||||
var created = (await (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct))
|
||||
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
|
||||
|
||||
// Ein Jahr entfällt (Vorkommen löschen)
|
||||
var in2031 = (await RangeAsync(admin, new DateOnly(2031, 3, 1), new DateOnly(2031, 3, 31))).Occurrences.Single(o => o.SeriesId == created.SeriesId);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/calendar/events/{in2031.EventId}?scope=Occurrence&occurrence={Key(in2031)}", Ct)).StatusCode);
|
||||
Assert.DoesNotContain((await RangeAsync(admin, new DateOnly(2031, 3, 1), new DateOnly(2031, 3, 31))).Occurrences, o => o.SeriesId == created.SeriesId);
|
||||
|
||||
// Geburtsdatum korrigiert: auch bei „nur dieses Vorkommen“ wird die ganze Serie neu aufgebaut, alte Ausnahmen entfallen
|
||||
var current = (await RangeAsync(admin, new DateOnly(2030, 3, 1), new DateOnly(2030, 3, 31))).Occurrences.Single(o => o.SeriesId == created.SeriesId);
|
||||
var update = await admin.PutAsJsonAsync($"/api/calendar/events/{current.EventId}?scope=Occurrence&occurrence={Key(current)}",
|
||||
Birthday(calendarId, name, new DateOnly(2018, 7, 20)) with { Id = null, Version = current.Version }, Ct);
|
||||
Assert.Equal(HttpStatusCode.NoContent, update.StatusCode);
|
||||
|
||||
var after = (await RangeAsync(admin, new DateOnly(2031, 1, 1), new DateOnly(2031, 12, 31))).Occurrences.Where(o => o.SeriesId == created.SeriesId).ToList();
|
||||
var occurrence = Assert.Single(after);
|
||||
Assert.Equal((new DateOnly(2031, 7, 20), 13, new DateOnly(2018, 7, 20)), (occurrence.StartDate, occurrence.Age, occurrence.BirthDate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Birth_date_in_events_is_validated_and_hidden_from_readers_who_cannot_edit()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var name = $"Cara{Guid.NewGuid():N}"[..12];
|
||||
|
||||
var future = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, future), Ct)).StatusCode);
|
||||
|
||||
var created = (await (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct))
|
||||
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
|
||||
|
||||
// Kind darf lesen, aber nicht ändern: Alter ja, Geburtsdatum nein
|
||||
var seenByKid = (await RangeAsync(kid, new DateOnly(2031, 3, 1), new DateOnly(2031, 3, 31))).Occurrences.Single(o => o.SeriesId == created.SeriesId);
|
||||
Assert.False(seenByKid.CanEdit);
|
||||
Assert.Null(seenByKid.BirthDate);
|
||||
Assert.Equal(13, seenByKid.Age);
|
||||
|
||||
// Eigene Geburtstage darf das Kind anlegen und sieht dann das Datum
|
||||
var own = (await (await kid.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, $"Oma{Guid.NewGuid():N}"[..10], new DateOnly(1950, 5, 17)), Ct))
|
||||
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
|
||||
Assert.Equal(new DateOnly(1950, 5, 17), own.BirthDate);
|
||||
Assert.Equal(new DateOnly(1951, 5, 17), own.StartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feed_exports_the_series_once_with_plain_title_and_birthday_category()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
var name = $"Feed{Guid.NewGuid():N}"[..12];
|
||||
await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct);
|
||||
|
||||
var secret = (await (await admin.PostAsJsonAsync("/api/calendar/feeds", new CreateIcsFeedRequest(null, IcsFeedScope.All, null, IcsTaskMode.None), Ct))
|
||||
.Content.ReadFromJsonAsync<IcsFeedSecretDto>(Ct))!;
|
||||
using var app = fixture.CreateBrowserClient();
|
||||
var body = (await app.GetStringAsync(new Uri(secret.HttpsUrl).AbsolutePath, Ct)).Replace("\r\n", "\n");
|
||||
|
||||
var vevent = body.Split("BEGIN:VEVENT").Where(e => e.Contains($"SUMMARY:Geburtstag von {name}\n", StringComparison.Ordinal)).Single();
|
||||
Assert.Contains("RRULE:FREQ=YEARLY", vevent);
|
||||
Assert.Contains("DTSTART;VALUE=DATE:20190305", vevent);
|
||||
Assert.Contains("Geburtstag", vevent.Split('\n').Single(l => l.StartsWith("CATEGORIES:", StringComparison.Ordinal)));
|
||||
}
|
||||
}
|
||||
@@ -579,4 +579,32 @@ public sealed class CalDavTests(AppFixture fixture)
|
||||
using var good = DavClient(user.UserName, secret);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, (await SendAsync(good, HttpMethod.Options, "/caldav/")).Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Birthday_series_roundtrips_via_caldav_and_keeps_its_birth_date_when_a_client_saves_it()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var calendarId = await FamilyCalendarIdAsync(admin);
|
||||
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
|
||||
var born = new DateOnly(2018, 3, 5);
|
||||
var name = $"Dav{Guid.NewGuid():N}"[..10];
|
||||
var request = new SaveEventRequest(Guid.NewGuid(), calendarId, $"Geburtstag von {name}", null, null, true, born, null, born, null, [], true, null, null, born);
|
||||
var series = (await (await admin.PostAsJsonAsync("/api/calendar/events", request, Ct)).Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
|
||||
var path = ResourcePath(calendarId, CalendarEvent.NewUid(series.SeriesId));
|
||||
|
||||
// Export: genau eine Serie mit dem schlichten Titel (das Alter steht nur in der Anzeige), Kategorie „Geburtstag“
|
||||
var get = (await SendAsync(dav, HttpMethod.Get, path)).Body;
|
||||
Assert.Equal(1, CountOf(get, "BEGIN:VEVENT"));
|
||||
Assert.Contains($"SUMMARY:Geburtstag von {name}", get);
|
||||
Assert.Contains("RRULE:FREQ=YEARLY", get);
|
||||
Assert.Contains("CATEGORIES:Geburtstag", get);
|
||||
|
||||
// Ein Client speichert die Serie zurück (mit Notiz, ohne Kategorie/BirthDate – kennt er nicht): Geburtstags-Eigenschaften bleiben
|
||||
var saved = get.Replace("SUMMARY:", "DESCRIPTION:Torte bestellen\r\nSUMMARY:", StringComparison.Ordinal);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, saved)).Response.StatusCode);
|
||||
|
||||
var occurrence = (await RangeAsync(admin, new DateTime(2031, 3, 1), new DateTime(2031, 3, 31))).Occurrences.Single(o => o.SeriesId == series.SeriesId);
|
||||
Assert.Equal((born, 13, "Torte bestellen"), (occurrence.BirthDate, occurrence.Age, occurrence.Notes));
|
||||
Assert.Equal($"Geburtstag von {name} (13 Jahre)", occurrence.DisplayTitle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using FamilyPlanner.Api.Features.Calendar;
|
||||
using FamilyPlanner.Api.Features.Calendar.Ics;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Domain.Recurrence;
|
||||
using FamilyPlanner.Domain.Users;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using FamilyPlanner.Shared.Calendar;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Calendar;
|
||||
|
||||
/// <summary>Geburtstage (Phase 15): reine Alters-/Datumslogik, Validatoren, Serienbildung und Anzeigetitel je Vorkommen.</summary>
|
||||
public sealed class BirthdayTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = FamilyTime.Berlin;
|
||||
private static readonly DateOnly Today = new(2026, 10, 11);
|
||||
|
||||
private static DateOnly D(int y, int m, int d) => new(y, m, d);
|
||||
|
||||
private static TimeProvider Clock() => new FixedClock(new DateTimeOffset(2026, 10, 11, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private sealed class FixedClock(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2018, 3, 5, 2026, 3, 4, 7)]
|
||||
[InlineData(2018, 3, 5, 2026, 3, 5, 8)]
|
||||
[InlineData(2018, 3, 5, 2026, 12, 31, 8)]
|
||||
[InlineData(2026, 10, 11, 2026, 10, 11, 0)]
|
||||
[InlineData(1980, 12, 31, 2026, 12, 30, 45)]
|
||||
public void Age_counts_completed_years(int by, int bm, int bd, int y, int m, int d, int expected)
|
||||
{
|
||||
Assert.Equal(expected, BirthdayRules.AgeOn(D(by, bm, bd), D(y, m, d)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Age_is_null_before_birth()
|
||||
{
|
||||
Assert.Null(BirthdayRules.AgeOn(D(2020, 5, 1), D(2020, 4, 30)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2025, 2, 27, 20)] // Nichtschaltjahr: erst am 28.2. das neue Lebensjahr
|
||||
[InlineData(2025, 2, 28, 21)]
|
||||
[InlineData(2025, 3, 1, 21)]
|
||||
[InlineData(2028, 2, 28, 23)] // Schaltjahr: erst am 29.2.
|
||||
[InlineData(2028, 2, 29, 24)]
|
||||
[InlineData(2028, 3, 1, 24)]
|
||||
public void Leap_day_birthdays_are_celebrated_on_feb_28_in_common_years(int y, int m, int d, int expected)
|
||||
{
|
||||
Assert.Equal(expected, BirthdayRules.AgeOn(D(2004, 2, 29), D(y, m, d)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Birthday_in_year_handles_leap_day()
|
||||
{
|
||||
var birth = D(2004, 2, 29);
|
||||
Assert.Equal(D(2025, 2, 28), BirthdayRules.BirthdayIn(birth, 2025));
|
||||
Assert.Equal(D(2028, 2, 29), BirthdayRules.BirthdayIn(birth, 2028));
|
||||
Assert.Equal(D(2025, 3, 5), BirthdayRules.BirthdayIn(D(2018, 3, 5), 2025));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1899, 12, 31, false)]
|
||||
[InlineData(1900, 1, 1, true)]
|
||||
[InlineData(2026, 10, 11, true)]
|
||||
[InlineData(2026, 10, 12, false)]
|
||||
public void Birth_date_must_be_between_1900_and_today(int y, int m, int d, bool valid)
|
||||
{
|
||||
Assert.Equal(valid, BirthdayRules.IsValidBirthDate(D(y, m, d), Today));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Series_rule_for_leap_day_yields_last_day_of_february_each_year()
|
||||
{
|
||||
var birth = D(2004, 2, 29);
|
||||
var start = BirthdayRules.FirstBirthday(birth); // 28.2.2005
|
||||
Assert.Equal(D(2005, 2, 28), start);
|
||||
|
||||
var rule = RecurrenceRule.Parse(BirthdayRules.RecurrenceRuleFor(birth));
|
||||
var dates = RecurrenceEvaluator.Occurrences(rule, start.ToDateTime(TimeOnly.MinValue), Berlin)
|
||||
.Take(5).Select(DateOnly.FromDateTime).ToList();
|
||||
|
||||
Assert.Equal([D(2005, 2, 28), D(2006, 2, 28), D(2007, 2, 28), D(2008, 2, 29), D(2009, 2, 28)], dates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Series_rule_for_normal_date_is_plain_yearly()
|
||||
{
|
||||
Assert.Equal("FREQ=YEARLY", BirthdayRules.RecurrenceRuleFor(D(2018, 3, 5)));
|
||||
Assert.Equal(D(2019, 3, 5), BirthdayRules.FirstBirthday(D(2018, 3, 5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Title_helpers_round_trip_and_format_age()
|
||||
{
|
||||
Assert.Equal("Geburtstag von Anna", BirthdayRules.TitleFor(" Anna "));
|
||||
Assert.Equal("Anna", BirthdayRules.NameFromTitle("Geburtstag von Anna"));
|
||||
Assert.Equal("Schwimmkurs", BirthdayRules.NameFromTitle("Schwimmkurs"));
|
||||
Assert.Equal("1 Jahr", BirthdayRules.AgeLabel(1));
|
||||
Assert.Equal("7 Jahre", BirthdayRules.AgeLabel(7));
|
||||
Assert.Equal("Geburtstag von Anna (7 Jahre)", BirthdayRules.DisplayTitle("Geburtstag von Anna", 7));
|
||||
Assert.Equal("Termin", BirthdayRules.DisplayTitle("Termin", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Birth_date_is_visible_to_self_admin_and_family_only()
|
||||
{
|
||||
var me = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
|
||||
Assert.True(BirthdayRules.CanView(me, [Roles.Child], me));
|
||||
Assert.True(BirthdayRules.CanView(me, [Roles.Guest], me));
|
||||
Assert.True(BirthdayRules.CanView(me, [Roles.Family], other));
|
||||
Assert.True(BirthdayRules.CanView(me, [Roles.Admin], other));
|
||||
Assert.False(BirthdayRules.CanView(me, [Roles.Child], other));
|
||||
Assert.False(BirthdayRules.CanView(me, [Roles.Guest], other));
|
||||
Assert.False(BirthdayRules.CanView(me, [], other));
|
||||
}
|
||||
|
||||
// ---------- Validatoren ----------
|
||||
|
||||
[Fact]
|
||||
public void Set_birth_date_validator_rejects_future_and_ancient_dates()
|
||||
{
|
||||
var validator = new SetBirthDateRequestValidator(Clock());
|
||||
|
||||
Assert.True(validator.Validate(new SetBirthDateRequest(null)).IsValid);
|
||||
Assert.True(validator.Validate(new SetBirthDateRequest(D(2026, 10, 11))).IsValid);
|
||||
Assert.False(validator.Validate(new SetBirthDateRequest(D(2026, 10, 12))).IsValid);
|
||||
Assert.False(validator.Validate(new SetBirthDateRequest(D(1899, 12, 31))).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_requests_validate_birth_date()
|
||||
{
|
||||
var create = new CreateUserRequest("anna", "Anna", null, Roles.Child, "#E53935", "Startpasswort1", null, D(2018, 3, 5));
|
||||
var update = new UpdateUserRequest("Anna", null, Roles.Child, "#E53935", true, null, D(2018, 3, 5));
|
||||
|
||||
Assert.True(new CreateUserRequestValidator(Clock()).Validate(create).IsValid);
|
||||
Assert.True(new UpdateUserRequestValidator(Clock()).Validate(update).IsValid);
|
||||
Assert.Contains(new CreateUserRequestValidator(Clock()).Validate(create with { BirthDate = D(2030, 1, 1) }).Errors, e => e.PropertyName == "BirthDate");
|
||||
Assert.Contains(new UpdateUserRequestValidator(Clock()).Validate(update with { BirthDate = D(1800, 1, 1) }).Errors, e => e.PropertyName == "BirthDate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Event_request_validates_birth_date()
|
||||
{
|
||||
var request = new SaveEventRequest(null, Guid.NewGuid(), "Geburtstag von Anna", null, null, true, D(2027, 3, 5), null, D(2027, 3, 5), null,
|
||||
[], true, "FREQ=YEARLY", null, D(2018, 3, 5));
|
||||
|
||||
Assert.True(new SaveEventRequestValidator(Clock()).Validate(request).IsValid);
|
||||
Assert.Contains(new SaveEventRequestValidator(Clock()).Validate(request with { BirthDate = D(2030, 1, 1) }).Errors, e => e.PropertyName == "BirthDate");
|
||||
}
|
||||
|
||||
// ---------- Server: Normalisierung und Anzeige je Vorkommen ----------
|
||||
|
||||
[Fact]
|
||||
public void Server_forces_all_day_yearly_series_from_the_birth_date()
|
||||
{
|
||||
var request = new SaveEventRequest(null, Guid.NewGuid(), "Geburtstag von Anna", null, null, false, D(2030, 1, 1), new TimeOnly(9, 0),
|
||||
D(2030, 1, 2), new TimeOnly(10, 0), [], false, "FREQ=DAILY", null, D(2018, 3, 5));
|
||||
|
||||
var normalized = CalendarEndpoints.NormalizeBirthday(request);
|
||||
|
||||
Assert.True(normalized.IsAllDay);
|
||||
Assert.Equal((D(2019, 3, 5), D(2019, 3, 5), (TimeOnly?)null, (TimeOnly?)null), (normalized.StartDate, normalized.EndDate, normalized.StartTime, normalized.EndTime));
|
||||
Assert.Equal("FREQ=YEARLY", normalized.RecurrenceRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Requests_without_birth_date_are_left_alone()
|
||||
{
|
||||
var request = new SaveEventRequest(null, Guid.NewGuid(), "Zahnarzt", null, null, false, D(2030, 1, 1), new TimeOnly(9, 0),
|
||||
D(2030, 1, 1), new TimeOnly(10, 0), [], false, null);
|
||||
|
||||
Assert.Same(request, CalendarEndpoints.NormalizeBirthday(request));
|
||||
}
|
||||
|
||||
private static CalendarEvent BirthdaySeries(DateOnly birth, Guid? createdBy = null)
|
||||
{
|
||||
var first = BirthdayRules.FirstBirthday(birth);
|
||||
var id = Guid.NewGuid();
|
||||
return new CalendarEvent
|
||||
{
|
||||
Id = id,
|
||||
CalendarId = Guid.NewGuid(),
|
||||
Uid = CalendarEvent.NewUid(id),
|
||||
Title = "Geburtstag von Anna",
|
||||
IsAllDay = true,
|
||||
StartUtc = FamilyTime.StartOfDayUtc(first, Berlin),
|
||||
EndUtc = FamilyTime.StartOfDayUtc(first.AddDays(1), Berlin),
|
||||
RecurrenceRule = BirthdayRules.RecurrenceRuleFor(birth),
|
||||
BirthDate = birth,
|
||||
CreatedBy = createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CalendarOccurrenceDto> Dtos(CalendarEvent series, DateOnly from, DateOnly to, CalendarAccess access = CalendarAccess.Write, Guid? userId = null)
|
||||
{
|
||||
var byId = new Dictionary<Guid, CalendarEvent> { [series.Id] = series };
|
||||
return CalendarTime.Expand([series], FamilyTime.StartOfDayUtc(from, Berlin), FamilyTime.StartOfDayUtc(to.AddDays(1), Berlin))
|
||||
.Select(o => CalendarEndpoints.ToDto(o, byId, access, userId ?? Guid.NewGuid(), Berlin))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_occurrence_carries_its_own_age_without_breaking_the_series()
|
||||
{
|
||||
var series = BirthdaySeries(D(2018, 3, 5));
|
||||
|
||||
var dtos = Dtos(series, D(2025, 1, 1), D(2027, 12, 31));
|
||||
|
||||
Assert.Equal([D(2025, 3, 5), D(2026, 3, 5), D(2027, 3, 5)], dtos.Select(d => d.StartDate));
|
||||
Assert.Equal([7, 8, 9], dtos.Select(d => d.Age!.Value));
|
||||
Assert.Equal(["Geburtstag von Anna (7 Jahre)", "Geburtstag von Anna (8 Jahre)", "Geburtstag von Anna (9 Jahre)"], dtos.Select(d => d.DisplayTitle));
|
||||
Assert.All(dtos, d => Assert.Equal("Geburtstag von Anna", d.Title));
|
||||
Assert.All(dtos, d => Assert.True(d.IsAllDay && d.IsRecurring));
|
||||
Assert.All(dtos, d => Assert.Equal(series.Id, d.SeriesId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leap_day_series_shows_feb_28_in_common_years_with_correct_age()
|
||||
{
|
||||
var series = BirthdaySeries(D(2004, 2, 29));
|
||||
|
||||
var dtos = Dtos(series, D(2027, 1, 1), D(2028, 12, 31));
|
||||
|
||||
Assert.Equal([(D(2027, 2, 28), 23), (D(2028, 2, 29), 24)], dtos.Select(d => (d.StartDate, d.Age!.Value)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Birth_date_is_only_exposed_to_users_who_may_edit_the_event()
|
||||
{
|
||||
var owner = Guid.NewGuid();
|
||||
var series = BirthdaySeries(D(2018, 3, 5), owner);
|
||||
|
||||
var editor = Dtos(series, D(2026, 3, 1), D(2026, 3, 31), CalendarAccess.WriteOwn, owner).Single();
|
||||
var reader = Dtos(series, D(2026, 3, 1), D(2026, 3, 31), CalendarAccess.Read).Single();
|
||||
|
||||
Assert.Equal(D(2018, 3, 5), editor.BirthDate);
|
||||
Assert.Null(reader.BirthDate);
|
||||
Assert.Equal(8, reader.Age); // Das Alter selbst ist Teil der Anzeige
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plain_events_have_no_age()
|
||||
{
|
||||
var series = BirthdaySeries(D(2018, 3, 5));
|
||||
series.BirthDate = null;
|
||||
|
||||
var dto = Dtos(series, D(2026, 3, 1), D(2026, 3, 31)).Single();
|
||||
|
||||
Assert.Null(dto.Age);
|
||||
Assert.Equal(dto.Title, dto.DisplayTitle);
|
||||
}
|
||||
|
||||
// ---------- ICS ----------
|
||||
|
||||
[Fact]
|
||||
public void Ics_export_keeps_one_series_with_plain_title_and_birthday_category()
|
||||
{
|
||||
var series = BirthdaySeries(D(2018, 3, 5));
|
||||
series.UpdatedAt = series.CreatedAt = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var ics = IcsBuilder.Build("Familie", "Europe/Berlin", [series], new Dictionary<Guid, string> { [series.CalendarId] = "Familie" },
|
||||
new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true).Replace("\r\n", "\n");
|
||||
|
||||
Assert.Single(ics.Split('\n'), l => l.StartsWith("BEGIN:VEVENT", StringComparison.Ordinal));
|
||||
Assert.Contains("SUMMARY:Geburtstag von Anna\n", ics);
|
||||
Assert.Contains("RRULE:FREQ=YEARLY", ics);
|
||||
Assert.Contains("DTSTART;VALUE=DATE:20190305", ics);
|
||||
Assert.Contains("CATEGORIES:" + IcsBuilder.BirthdayCategory, ics);
|
||||
Assert.DoesNotContain("2018", ics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@* Testhülle: MudBlazor-Provider (Datumsauswahl, Snackbar) + „Meine Einstellungen“. *@
|
||||
<MudPopoverProvider />
|
||||
<MudSnackbarProvider />
|
||||
<FamilyPlanner.Web.Client.Pages.AccountSettings />
|
||||
@@ -9,19 +9,21 @@ namespace FamilyPlanner.Web.Tests.Account;
|
||||
public sealed class AccountSettingsPageTests : BunitContext
|
||||
{
|
||||
private readonly ThemeState _theme;
|
||||
private readonly FakeProfileApi _profile = new();
|
||||
|
||||
public AccountSettingsPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
Services.AddMudServices();
|
||||
_theme = new ThemeState(JSInterop.JSRuntime);
|
||||
Services.AddSingleton(_theme);
|
||||
Services.AddSingleton<IProfileApi>(_profile);
|
||||
Services.AddMudServices();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offers_the_three_theme_choices_and_links_to_notifications()
|
||||
{
|
||||
var cut = Render<AccountSettings>();
|
||||
var cut = Render<AccountSettingsHost>();
|
||||
|
||||
var labels = cut.FindAll(".fp-theme-choice label").Select(l => l.TextContent.Trim()).ToList();
|
||||
Assert.Equal(["Wie das Gerät", "Hell", "Dunkel"], labels);
|
||||
@@ -31,7 +33,7 @@ public sealed class AccountSettingsPageTests : BunitContext
|
||||
[Fact]
|
||||
public void Links_to_the_calendar_phone_connection()
|
||||
{
|
||||
var cut = Render<AccountSettings>();
|
||||
var cut = Render<AccountSettingsHost>();
|
||||
|
||||
Assert.Equal("konto/kalender-verbinden", cut.Find(".fp-settings-caldav").GetAttribute("href"));
|
||||
Assert.Contains("Mit Handy verbinden", cut.Markup);
|
||||
@@ -40,11 +42,51 @@ public sealed class AccountSettingsPageTests : BunitContext
|
||||
[Fact]
|
||||
public void Choosing_dark_updates_the_theme_state()
|
||||
{
|
||||
var cut = Render<AccountSettings>();
|
||||
var cut = Render<AccountSettingsHost>();
|
||||
|
||||
cut.FindAll(".fp-theme-choice input")[2].Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(ThemePreference.Dark, _theme.Preference));
|
||||
Assert.True(_theme.IsDarkMode);
|
||||
}
|
||||
[Fact]
|
||||
public void Shows_the_own_birth_date_with_age_and_saves_changes()
|
||||
{
|
||||
var born = FamilyPlanner.Domain.Common.FamilyTime.Today(TimeProvider.System).AddYears(-9).AddDays(-2);
|
||||
_profile.BirthDate = born;
|
||||
var cut = Render<AccountSettingsHost>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal($"{born:dd.MM.yyyy}", cut.Find("#profile-birthdate").GetAttribute("value")));
|
||||
Assert.Contains("Alter: 9 Jahre", cut.Markup);
|
||||
|
||||
cut.Find("#profile-birthdate").Change("05.03.2018");
|
||||
cut.Find(".fp-profile-birthdate-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(new DateOnly(2018, 3, 5), Assert.Single(_profile.Saved)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_a_future_birth_date_before_calling_the_api()
|
||||
{
|
||||
var cut = Render<AccountSettingsHost>();
|
||||
|
||||
cut.Find("#profile-birthdate").Change("05.03.2999");
|
||||
cut.Find(".fp-profile-birthdate-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("nicht in der Zukunft", cut.Markup));
|
||||
Assert.Empty(_profile.Saved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_the_birth_date_removes_it()
|
||||
{
|
||||
_profile.BirthDate = new DateOnly(2018, 3, 5);
|
||||
var cut = Render<AccountSettingsHost>();
|
||||
cut.WaitForAssertion(() => Assert.Equal("05.03.2018", cut.Find("#profile-birthdate").GetAttribute("value")));
|
||||
|
||||
cut.Find("#profile-birthdate").Change(string.Empty);
|
||||
cut.Find(".fp-profile-birthdate-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Null(Assert.Single(_profile.Saved)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Account;
|
||||
|
||||
internal sealed class FakeProfileApi : IProfileApi
|
||||
{
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
|
||||
public List<DateOnly?> Saved { get; } = [];
|
||||
|
||||
public ApiProblem? NextProblem { get; set; }
|
||||
|
||||
private CurrentUserDto Me() => new(Guid.NewGuid(), "anna", "Anna", null, "#E53935", ["Kind"], false, BirthDate: BirthDate);
|
||||
|
||||
public Task<ApiResult<CurrentUserDto>> GetMeAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<CurrentUserDto>.Ok(Me()));
|
||||
|
||||
public Task<ApiResult<CurrentUserDto>> SetBirthDateAsync(DateOnly? birthDate, CancellationToken ct = default)
|
||||
{
|
||||
if (NextProblem is { } problem)
|
||||
{
|
||||
NextProblem = null;
|
||||
return Task.FromResult(ApiResult<CurrentUserDto>.Fail(problem));
|
||||
}
|
||||
|
||||
Saved.Add(birthDate);
|
||||
BirthDate = birthDate;
|
||||
return Task.FromResult(ApiResult<CurrentUserDto>.Ok(Me()));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,53 @@ public sealed class UserManagementTests : BunitContext
|
||||
Assert.Contains("PIN", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_birth_date_with_computed_age()
|
||||
{
|
||||
var today = FamilyPlanner.Domain.Common.FamilyTime.Today(TimeProvider.System);
|
||||
var born = today.AddYears(-7).AddDays(-1);
|
||||
_api.Users[1] = _api.Users[1] with { BirthDate = born };
|
||||
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
var cells = cut.FindAll(".fp-user-birthdate").Select(c => c.TextContent.Trim()).ToList();
|
||||
Assert.Equal(["–", $"{born:dd.MM.yyyy} (7 Jahre)", "–"], cells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_dialog_sends_the_birth_date()
|
||||
{
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
cut.Find("button.fp-add-user").Click();
|
||||
cut.WaitForElement("#user-edit-form");
|
||||
cut.Find("#display-name").Change("Max");
|
||||
cut.Find("#user-name").Change("max");
|
||||
cut.Find("#password").Change("Startpasswort1");
|
||||
cut.Find("#birthdate").Change("05.03.2018");
|
||||
cut.Find("#user-edit-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Created));
|
||||
Assert.Equal(new DateOnly(2018, 3, 5), _api.Created[0].BirthDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_dialog_rejects_a_future_birth_date()
|
||||
{
|
||||
var cut = Render<UserManagementHost>();
|
||||
|
||||
cut.Find("button.fp-add-user").Click();
|
||||
cut.WaitForElement("#user-edit-form");
|
||||
cut.Find("#display-name").Change("Max");
|
||||
cut.Find("#user-name").Change("max");
|
||||
cut.Find("#password").Change("Startpasswort1");
|
||||
cut.Find("#birthdate").Change("05.03.2999");
|
||||
cut.Find("#user-edit-form").Submit();
|
||||
|
||||
Assert.Contains("nicht in der Zukunft", cut.Markup);
|
||||
Assert.Empty(_api.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_dialog_validates_before_calling_the_api()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Shared.Calendar;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using FamilyPlanner.Web.Tests.Shopping;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Calendar;
|
||||
|
||||
/// <summary>Kalender-Vorlage „Geburtstag“: Person aus dem Verzeichnis oder freie Eingabe, Anzeige mit Alter je Vorkommen.</summary>
|
||||
public sealed class BirthdayTemplateTests : BunitContext
|
||||
{
|
||||
private readonly FakeCalendarApi _api = new();
|
||||
private readonly DateOnly _today = FamilyTime.Today(TimeProvider.System);
|
||||
|
||||
public BirthdayTemplateTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
Services.AddMudServices();
|
||||
Services.AddSingleton<ICalendarApi>(_api);
|
||||
Services.AddSingleton<ILiveConnection>(new FakeLiveConnection());
|
||||
Services.AddSingleton<ILocalStore, InMemoryLocalStore>();
|
||||
Services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
private static string Text(AngleSharp.Dom.IElement element) => element.TextContent.Trim();
|
||||
|
||||
private void ChooseBirthdayTemplate(IRenderedComponent<CalendarHost> cut)
|
||||
{
|
||||
cut.Find(".fp-fab").Click();
|
||||
cut.WaitForElement(".fp-event-template");
|
||||
cut.Find(".fp-event-template").MouseDown();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".mud-list-item")));
|
||||
cut.FindAll(".mud-list-item").First(i => Text(i) == "Geburtstag").Click();
|
||||
cut.WaitForElement("#birthday-name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Picking_a_person_prefills_name_and_birth_date_and_saves_a_yearly_all_day_series()
|
||||
{
|
||||
var cut = Render<CalendarHost>();
|
||||
ChooseBirthdayTemplate(cut);
|
||||
Assert.Empty(cut.FindAll("#event-title")); // keine Zeiten/Titelfelder bei der Vorlage
|
||||
|
||||
cut.Find(".fp-birthday-person").MouseDown();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".mud-list-item")));
|
||||
cut.FindAll(".mud-list-item").First(i => Text(i) == "Anna").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Anna", cut.Find("#birthday-name").GetAttribute("value")));
|
||||
Assert.Equal("2018-03-05", cut.Find("#birthday-date").GetAttribute("value"));
|
||||
|
||||
cut.Find("#event-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Created));
|
||||
var request = _api.Created[0];
|
||||
Assert.Equal("Geburtstag von Anna", request.Title);
|
||||
Assert.True(request.IsAllDay);
|
||||
Assert.Equal(new DateOnly(2018, 3, 5), request.BirthDate);
|
||||
Assert.Equal(new DateOnly(2019, 3, 5), request.StartDate);
|
||||
Assert.Equal("FREQ=YEARLY", request.RecurrenceRule);
|
||||
Assert.True(request.ForWholeFamily);
|
||||
Assert.True(new SaveEventRequestValidator().Validate(request).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Free_entry_of_name_and_birth_date_works_for_leap_day()
|
||||
{
|
||||
var cut = Render<CalendarHost>();
|
||||
ChooseBirthdayTemplate(cut);
|
||||
|
||||
cut.Find("#birthday-name").Change("Oma Erna");
|
||||
cut.Find("#birthday-date").Change("1952-02-29");
|
||||
cut.Find("#event-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Created));
|
||||
var request = _api.Created[0];
|
||||
Assert.Equal("Geburtstag von Oma Erna", request.Title);
|
||||
Assert.Equal(new DateOnly(1952, 2, 29), request.BirthDate);
|
||||
Assert.Equal(new DateOnly(1953, 2, 28), request.StartDate);
|
||||
Assert.Equal("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1", request.RecurrenceRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_name_or_date_is_reported_and_nothing_is_saved()
|
||||
{
|
||||
var cut = Render<CalendarHost>();
|
||||
ChooseBirthdayTemplate(cut);
|
||||
|
||||
cut.Find("#event-form").Submit();
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben.", cut.Markup));
|
||||
|
||||
cut.Find("#birthday-name").Change("Anna");
|
||||
cut.Find("#event-form").Submit();
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bitte das Geburtsdatum angeben.", cut.Markup));
|
||||
Assert.Empty(_api.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Future_birth_date_is_rejected_by_the_shared_validator()
|
||||
{
|
||||
var cut = Render<CalendarHost>();
|
||||
ChooseBirthdayTemplate(cut);
|
||||
|
||||
cut.Find("#birthday-name").Change("Baby");
|
||||
cut.Find("#birthday-date").Change(_today.AddYears(1).ToString("yyyy-MM-dd"));
|
||||
cut.Find("#event-form").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("nicht in der Zukunft", cut.Markup));
|
||||
Assert.Empty(_api.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Agenda_shows_the_age_of_each_occurrence()
|
||||
{
|
||||
var birthday = FakeCalendarApi.Occurrence("Geburtstag von Anna", _today, rule: "FREQ=YEARLY")
|
||||
with { Age = 7, BirthDate = new DateOnly(2018, 3, 5) };
|
||||
_api.Occurrences.Add(birthday);
|
||||
|
||||
var cut = Render<CalendarHost>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Geburtstag von Anna (7 Jahre)", cut.Markup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_a_birthday_opens_the_template_directly_without_the_series_question()
|
||||
{
|
||||
var birthday = FakeCalendarApi.Occurrence("Geburtstag von Anna", _today, rule: "FREQ=YEARLY")
|
||||
with { Age = 7, BirthDate = new DateOnly(2018, 3, 5) };
|
||||
_api.Occurrences.Add(birthday);
|
||||
var cut = Render<CalendarHost>();
|
||||
|
||||
cut.WaitForElement("[aria-label^='Termin: Geburtstag von Anna']").Click();
|
||||
|
||||
cut.WaitForElement("#birthday-name");
|
||||
Assert.Equal("Anna", cut.Find("#birthday-name").GetAttribute("value"));
|
||||
Assert.Equal("2018-03-05", cut.Find("#birthday-date").GetAttribute("value"));
|
||||
Assert.Empty(cut.FindAll(".fp-event-template")); // Vorlage nur beim Anlegen
|
||||
Assert.DoesNotContain("Nur diesen", cut.Markup);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace FamilyPlanner.Web.Tests.Calendar;
|
||||
internal sealed class FakeCalendarApi : ICalendarApi
|
||||
{
|
||||
public static readonly Guid FamilyCalendarId = Guid.Parse("00000000-0000-0000-0000-0000000000c1");
|
||||
public static readonly UserDirectoryEntry Anna = new(Guid.Parse("00000000-0000-0000-0000-0000000000a1"), "Anna", "#E53935", null, Roles.Child);
|
||||
public static readonly UserDirectoryEntry Anna = new(Guid.Parse("00000000-0000-0000-0000-0000000000a1"), "Anna", "#E53935", null, Roles.Child, new DateOnly(2018, 3, 5));
|
||||
public static readonly UserDirectoryEntry Papa = new(Guid.Parse("00000000-0000-0000-0000-0000000000a2"), "Papa", "#1E88E5", null, Roles.Family);
|
||||
|
||||
public List<CalendarDto> Calendars { get; } = [new(FamilyCalendarId, "Familie", "#3949AB", true, 0, CalendarAccess.Write)];
|
||||
|
||||
Reference in new issue
Block a user