feat: add task domain with recurrence rules, chores and push model

Own RRULE subset (DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, BYDAY with ordinals, BYMONTHDAY, BYMONTH, COUNT/UNTIL) with a wall-time evaluator for Europe/Berlin, task entities (assignments, checklist, history, points ledger), visibility and permission rules, status/approval workflow, next-instance scheduling, views and web push subscriptions. New permission TasksApprove, shared contracts and validators, migration Phase5Tasks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
JannisandCopilot committed 2026-10-09 21:57:55 +02:00
1 parent 35c30fe223
commit 5e0458ac83
19 files changed
+3790

No files matched your search

@@ -30,6 +30,7 @@ public static class AuditActions
public const string UserAvatarChanged = "user.avatar_changed";
public const string UserPinChanged = "user.pin_changed";
public const string SettingsUpdated = "settings.updated";
public const string PointsAdjusted = "points.adjusted";
public static string DisplayName(string action) => action switch
{
@@ -41,6 +42,7 @@ public static class AuditActions
UserAvatarChanged => "Profilbild geändert",
UserPinChanged => "PIN geändert",
SettingsUpdated => "Einstellungen geändert",
PointsAdjusted => "Punkte korrigiert",
_ => action,
};
}
@@ -13,6 +13,7 @@ public enum Permission
TasksEdit,
TasksAssign,
TasksCompleteAssigned,
TasksApprove,
CalendarRead,
EventsEdit,
FamilyChat,
@@ -60,6 +61,7 @@ public static class PermissionMatrix
[Permission.TasksEdit] = [F, F, R, N], // Kind: nur eigene, private Aufgaben
[Permission.TasksAssign] = [F, F, N, N],
[Permission.TasksCompleteAssigned] = [F, F, F, F],
[Permission.TasksApprove] = [F, F, N, N], // Eltern-Bestätigung, Punktekorrektur
[Permission.CalendarRead] = [F, F, F, R], // Gast: nur freigegebene Kalender
[Permission.EventsEdit] = [F, F, R, N], // Kind: nur eigene Termine
[Permission.FamilyChat] = [F, F, F, N], // Gast: nur wenn freigegeben
@@ -22,4 +22,41 @@ public static class FamilyTime
/// <summary>Kalendertag eines UTC-Zeitpunkts in der Familienzeitzone.</summary>
public static DateOnly LocalDate(DateTimeOffset utc, TimeZoneInfo? zone = null) =>
DateOnly.FromDateTime(ToFamilyTime(utc, zone).DateTime);
/// <summary>Wanduhrzeit (ohne Offset) eines UTC-Zeitpunkts in der Familienzeitzone.</summary>
public static DateTime ToLocal(DateTimeOffset utc, TimeZoneInfo? zone = null) =>
DateTime.SpecifyKind(ToFamilyTime(utc, zone).DateTime, DateTimeKind.Unspecified);
/// <summary>
/// Wanduhrzeit der Familienzeitzone → UTC. Wie RFC 5545: Zeiten in der Lücke der Sommerzeitumstellung werden um die
/// Länge der Lücke nach vorn verschoben (02:30 → 03:30), doppelte Zeiten bei der Rückstellung gelten als erstes Auftreten.
/// </summary>
public static DateTimeOffset ToUtc(DateTime local, TimeZoneInfo? zone = null)
{
zone ??= Berlin;
local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
TimeSpan offset;
if (zone.IsInvalidTime(local))
{
offset = zone.GetUtcOffset(local.AddDays(-1)); // Offset vor der Umstellung
}
else if (zone.IsAmbiguousTime(local))
{
offset = zone.GetAmbiguousTimeOffsets(local).Max();
}
else
{
offset = zone.GetUtcOffset(local);
}
return new DateTimeOffset(local, offset).ToUniversalTime();
}
/// <summary>Beginn (00:00) eines Kalendertags in der Familienzeitzone, als UTC.</summary>
public static DateTimeOffset StartOfDayUtc(DateOnly date, TimeZoneInfo? zone = null) =>
ToUtc(date.ToDateTime(TimeOnly.MinValue), zone);
/// <summary>Montag der Woche (ISO 8601, Woche beginnt am Montag), in der <paramref name="date"/> liegt.</summary>
public static DateOnly StartOfWeek(DateOnly date) => date.AddDays(-(((int)date.DayOfWeek + 6) % 7));
}
@@ -0,0 +1,89 @@
using System.Security.Cryptography;
using System.Text;
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Push;
/// <summary>Web-Push-Abo eines Geräts/Browsers (ADR-0014). Wird beim Abmelden des Abos oder bei 404/410 des Push-Dienstes gelöscht.</summary>
public sealed class PushSubscription : AuditableEntity
{
public const int EndpointMaxLength = 2000;
public const int KeyMaxLength = 200;
public const int UserAgentMaxLength = 300;
public Guid UserId { get; set; }
public string Endpoint { get; set; } = string.Empty;
/// <summary>SHA-256 des Endpunkts (eindeutiger Index – der Endpunkt selbst ist für einen Index zu lang).</summary>
public string EndpointHash { get; set; } = string.Empty;
/// <summary>Öffentlicher ECDH-Schlüssel des Browsers (P-256, Base64url).</summary>
public string P256dh { get; set; } = string.Empty;
/// <summary>Authentifizierungsgeheimnis des Browsers (16 Byte, Base64url).</summary>
public string Auth { get; set; } = string.Empty;
public string? UserAgent { get; set; }
public DateTimeOffset? LastSuccessAt { get; set; }
public int FailureCount { get; set; }
public static string Hash(string endpoint) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(endpoint)));
}
/// <summary>Regeln für Push-Endpunkte: nur HTTPS zu bekannten Push-Diensten (Schutz vor SSRF).</summary>
public static class PushRules
{
/// <summary>Push-Dienste von Chrome/Edge (FCM), Firefox (Mozilla), Safari (Apple) und Windows (WNS).</summary>
public static IReadOnlyList<string> DefaultAllowedHosts { get; } =
[
"fcm.googleapis.com",
"push.services.mozilla.com",
"push.apple.com",
"notify.windows.com",
];
public static bool IsAllowedEndpoint(string? endpoint, IEnumerable<string>? allowedHosts = null)
{
if (string.IsNullOrWhiteSpace(endpoint) || endpoint.Length > PushSubscription.EndpointMaxLength
|| !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps
|| !string.IsNullOrEmpty(uri.UserInfo) || !uri.IsDefaultPort)
{
return false;
}
var host = uri.IdnHost.ToLowerInvariant();
return (allowedHosts ?? DefaultAllowedHosts).Any(allowed =>
host == allowed || host.EndsWith("." + allowed, StringComparison.Ordinal));
}
/// <summary>Base64url ohne Padding dekodieren; <c>null</c> bei ungültiger Eingabe.</summary>
public static byte[]? FromBase64Url(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > PushSubscription.KeyMaxLength)
{
return null;
}
var base64 = value.Trim().Replace('-', '+').Replace('_', '/');
base64 = base64.PadRight(base64.Length + ((4 - (base64.Length % 4)) % 4), '=');
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
return null;
}
}
public static string ToBase64Url(ReadOnlySpan<byte> data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
/// <summary>Gültige Browser-Schlüssel: P-256-Punkt unkomprimiert (65 Byte, beginnt mit 0x04) und 16 Byte Auth-Geheimnis.</summary>
public static bool AreValidKeys(string? p256dh, string? auth) =>
FromBase64Url(p256dh) is { Length: 65 } key && key[0] == 0x04 && FromBase64Url(auth) is { Length: 16 };
}
@@ -0,0 +1,262 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Recurrence;
/// <summary>
/// Berechnet die Vorkommen einer <see cref="RecurrenceRule"/> in <b>Wanduhrzeit</b> der Familienzeitzone (ADR-0014).
/// Die Uhrzeit des Starts bleibt über Sommer-/Winterzeit hinweg gleich; erst die Umrechnung nach UTC
/// (<see cref="FamilyTime.ToUtc"/>) berücksichtigt den Offset. Ungültige Tage (31. im April, 29.02. außerhalb von
/// Schaltjahren) werden wie in RFC 5545 übersprungen. Der Start (DTSTART) ist immer das erste Vorkommen und zählt für COUNT mit.
/// </summary>
public static class RecurrenceEvaluator
{
/// <summary>Schutz vor Regeln, die nie (wieder) zutreffen, z. B. 30. Februar.</summary>
private const int MaxPeriods = 100_000;
private const int MaxYear = 9998;
private static readonly int MaxDayNumber = new DateOnly(MaxYear, 12, 24).DayNumber;
/// <summary>Alle Vorkommen ab <paramref name="start"/> (inklusive). Ohne COUNT/UNTIL praktisch unendlich – mit <c>Take</c> begrenzen.</summary>
public static IEnumerable<DateTime> Occurrences(RecurrenceRule rule, DateTime start, TimeZoneInfo? zone = null)
{
ArgumentNullException.ThrowIfNull(rule);
start = DateTime.SpecifyKind(start, DateTimeKind.Unspecified);
zone ??= FamilyTime.Berlin;
if (ExceedsUntil(rule, start, zone))
{
yield break;
}
yield return start;
var emitted = 1;
if (rule.Count is { } total && emitted >= total)
{
yield break;
}
var startDate = DateOnly.FromDateTime(start);
var time = TimeOnly.FromDateTime(start);
var interval = Math.Max(1, rule.Interval);
for (var period = 0; period < MaxPeriods; period++)
{
var candidates = Candidates(rule, startDate, (long)period * interval);
if (candidates is null)
{
yield break; // Kalenderende erreicht
}
foreach (var date in candidates)
{
var occurrence = date.ToDateTime(time);
if (occurrence <= start)
{
continue;
}
if (ExceedsUntil(rule, occurrence, zone))
{
yield break;
}
yield return occurrence;
emitted++;
if (rule.Count is { } count && emitted >= count)
{
yield break;
}
}
}
}
/// <summary>Erstes Vorkommen echt nach <paramref name="after"/> oder <c>null</c>, wenn die Serie vorher endet.</summary>
public static DateTime? NextAfter(RecurrenceRule rule, DateTime start, DateTime after, TimeZoneInfo? zone = null)
{
foreach (var occurrence in Occurrences(rule, start, zone))
{
if (occurrence > after)
{
return occurrence;
}
}
return null;
}
/// <summary>Alle Vorkommen im halboffenen Bereich [<paramref name="from"/>, <paramref name="to"/>) – z. B. für Kalenderansichten.</summary>
public static IEnumerable<DateTime> Between(RecurrenceRule rule, DateTime start, DateTime from, DateTime to, TimeZoneInfo? zone = null)
{
foreach (var occurrence in Occurrences(rule, start, zone))
{
if (occurrence >= to)
{
yield break;
}
if (occurrence >= from)
{
yield return occurrence;
}
}
}
/// <summary>Wie <see cref="Occurrences"/>, aber als UTC-Zeitpunkte (Sommerzeit-Lücken/-Doppelungen nach RFC 5545).</summary>
public static IEnumerable<DateTimeOffset> OccurrencesUtc(RecurrenceRule rule, DateTime start, TimeZoneInfo? zone = null) =>
Occurrences(rule, start, zone).Select(o => FamilyTime.ToUtc(o, zone));
private static bool ExceedsUntil(RecurrenceRule rule, DateTime occurrence, TimeZoneInfo zone) =>
(rule.UntilDate is { } untilDate && DateOnly.FromDateTime(occurrence) > untilDate)
|| (rule.UntilUtc is { } untilUtc && FamilyTime.ToUtc(occurrence, zone) > untilUtc);
/// <summary>Kandidaten (aufsteigend, ohne Duplikate) der n-ten Periode; <c>null</c> am Kalenderende.</summary>
private static List<DateOnly>? Candidates(RecurrenceRule rule, DateOnly start, long offset)
{
switch (rule.Frequency)
{
case RecurrenceFrequency.Daily:
{
if (start.DayNumber + offset > MaxDayNumber)
{
return null;
}
var day = start.AddDays((int)offset);
return MatchesMonth(rule, day.Month) && MatchesMonthDay(rule, day) && MatchesWeekday(rule, day) ? [day] : [];
}
case RecurrenceFrequency.Weekly:
{
var weekStart = FamilyTime.StartOfWeek(start);
if (weekStart.DayNumber + offset * 7 > MaxDayNumber)
{
return null;
}
weekStart = weekStart.AddDays((int)(offset * 7));
var days = rule.ByDay.Count > 0 ? rule.ByDay.Select(d => d.Day) : [start.DayOfWeek];
return days
.Select(d => weekStart.AddDays(((int)d + 6) % 7))
.Where(d => MatchesMonth(rule, d.Month))
.Distinct()
.Order()
.ToList();
}
case RecurrenceFrequency.Monthly:
{
var monthIndex = start.Year * 12L + start.Month - 1 + offset;
var year = (int)(monthIndex / 12);
if (year > MaxYear)
{
return null;
}
var month = (int)(monthIndex % 12) + 1;
return MatchesMonth(rule, month) ? DaysInMonth(rule, start, year, month) : [];
}
case RecurrenceFrequency.Yearly:
{
var year = start.Year + offset;
if (year > MaxYear)
{
return null;
}
return DaysInYear(rule, start, (int)year);
}
default:
return [];
}
}
private static List<DateOnly> DaysInMonth(RecurrenceRule rule, DateOnly start, int year, int month)
{
IEnumerable<DateOnly> days;
if (rule.ByMonthDay.Count > 0)
{
days = ResolveMonthDays(rule.ByMonthDay, year, month).Where(d => MatchesWeekday(rule, d));
}
else if (rule.ByDay.Count > 0)
{
days = rule.ByDay.SelectMany(d => WeekdaysInRange(d, new DateOnly(year, month, 1), new DateOnly(year, month, DateTime.DaysInMonth(year, month))));
}
else
{
days = start.Day <= DateTime.DaysInMonth(year, month) ? [new DateOnly(year, month, start.Day)] : [];
}
return days.Distinct().Order().ToList();
}
private static List<DateOnly> DaysInYear(RecurrenceRule rule, DateOnly start, int year)
{
IEnumerable<DateOnly> days;
var months = rule.ByMonth.Count > 0 ? rule.ByMonth : null;
if (rule.ByMonthDay.Count > 0)
{
days = (months ?? [start.Month])
.SelectMany(m => ResolveMonthDays(rule.ByMonthDay, year, m))
.Where(d => MatchesWeekday(rule, d));
}
else if (rule.ByDay.Count > 0)
{
days = months is null
? rule.ByDay.SelectMany(d => WeekdaysInRange(d, new DateOnly(year, 1, 1), new DateOnly(year, 12, 31)))
: months.SelectMany(m => rule.ByDay.SelectMany(d =>
WeekdaysInRange(d, new DateOnly(year, m, 1), new DateOnly(year, m, DateTime.DaysInMonth(year, m)))));
}
else
{
days = (months ?? [start.Month])
.Where(m => start.Day <= DateTime.DaysInMonth(year, m))
.Select(m => new DateOnly(year, m, start.Day));
}
return days.Distinct().Order().ToList();
}
/// <summary>Monatstage auflösen: positive vom Anfang, negative vom Ende; nicht existierende Tage entfallen.</summary>
private static IEnumerable<DateOnly> ResolveMonthDays(IEnumerable<int> monthDays, int year, int month)
{
var length = DateTime.DaysInMonth(year, month);
foreach (var value in monthDays)
{
var day = value > 0 ? value : length + 1 + value;
if (day >= 1 && day <= length)
{
yield return new DateOnly(year, month, day);
}
}
}
/// <summary>Alle passenden Wochentage im Bereich bzw. nur der n-te (vom Anfang oder – negativ – vom Ende).</summary>
private static IEnumerable<DateOnly> WeekdaysInRange(WeekdayNum weekday, DateOnly first, DateOnly last)
{
var firstMatch = first.AddDays(((int)weekday.Day - (int)first.DayOfWeek + 7) % 7);
var all = new List<DateOnly>();
for (var day = firstMatch; day <= last; day = day.AddDays(7))
{
all.Add(day);
}
return weekday.Ordinal switch
{
0 => all,
> 0 when weekday.Ordinal <= all.Count => [all[weekday.Ordinal - 1]],
< 0 when -weekday.Ordinal <= all.Count => [all[all.Count + weekday.Ordinal]],
_ => [],
};
}
private static bool MatchesMonth(RecurrenceRule rule, int month) => rule.ByMonth.Count == 0 || rule.ByMonth.Contains(month);
private static bool MatchesWeekday(RecurrenceRule rule, DateOnly day) =>
rule.ByDay.Count == 0 || rule.ByDay.Any(d => d.Day == day.DayOfWeek);
private static bool MatchesMonthDay(RecurrenceRule rule, DateOnly day) =>
rule.ByMonthDay.Count == 0 || ResolveMonthDays(rule.ByMonthDay, day.Year, day.Month).Contains(day);
}
@@ -0,0 +1,397 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
namespace FamilyPlanner.Domain.Recurrence;
public enum RecurrenceFrequency
{
Daily,
Weekly,
Monthly,
Yearly,
}
/// <summary>Wochentag mit optionaler Ordnungszahl (<c>MO</c>, <c>2TU</c> = 2. Dienstag, <c>-1FR</c> = letzter Freitag).</summary>
public readonly record struct WeekdayNum(DayOfWeek Day, int Ordinal = 0)
{
public override string ToString() => (Ordinal == 0 ? string.Empty : Ordinal.ToString(CultureInfo.InvariantCulture)) + RecurrenceRule.DayCode(Day);
}
/// <summary>
/// Teilmenge von RFC 5545 RRULE (ADR-0014): <c>FREQ</c> = DAILY/WEEKLY/MONTHLY/YEARLY, <c>INTERVAL</c>, <c>BYDAY</c> (mit Ordnungszahl
/// bei MONTHLY/YEARLY), <c>BYMONTHDAY</c> (auch negativ), <c>BYMONTH</c>, <c>COUNT</c> oder <c>UNTIL</c>, <c>WKST=MO</c>.
/// Wird für Aufgaben (Phase 5) und Termine/ICS (Phase 6) verwendet; <see cref="ToString"/> liefert eine gültige RRULE.
/// </summary>
public sealed record RecurrenceRule
{
public const int MaxLength = 250;
public const int MaxInterval = 999;
public const int MaxCount = 9999;
private static readonly string[] DayCodes = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
private static readonly string[] GermanDays = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
public RecurrenceFrequency Frequency { get; init; }
public int Interval { get; init; } = 1;
public IReadOnlyList<WeekdayNum> ByDay { get; init; } = [];
public IReadOnlyList<int> ByMonthDay { get; init; } = [];
public IReadOnlyList<int> ByMonth { get; init; } = [];
/// <summary>Gesamtzahl der Vorkommen inklusive des ersten (DTSTART).</summary>
public int? Count { get; init; }
/// <summary>Letzter erlaubter Kalendertag (inklusive), als <c>UNTIL=YYYYMMDD</c>.</summary>
public DateOnly? UntilDate { get; init; }
/// <summary>Letzter erlaubter Zeitpunkt (inklusive), als <c>UNTIL=YYYYMMDDTHHMMSSZ</c>.</summary>
public DateTimeOffset? UntilUtc { get; init; }
public bool HasEnd => Count is not null || UntilDate is not null || UntilUtc is not null;
public static RecurrenceRule Daily(int interval = 1) => new() { Frequency = RecurrenceFrequency.Daily, Interval = interval };
public static RecurrenceRule Weekly(int interval = 1, params DayOfWeek[] days) =>
new() { Frequency = RecurrenceFrequency.Weekly, Interval = interval, ByDay = days.Select(d => new WeekdayNum(d)).ToList() };
public static RecurrenceRule Monthly(int interval = 1) => new() { Frequency = RecurrenceFrequency.Monthly, Interval = interval };
public static RecurrenceRule Yearly(int interval = 1) => new() { Frequency = RecurrenceFrequency.Yearly, Interval = interval };
public static RecurrenceRule Parse(string text) =>
TryParse(text, out var rule, out var error) ? rule : throw new FormatException(error);
/// <summary>Prüft und liest eine Regel (optional mit Präfix <c>RRULE:</c>). Fehlertexte sind für die Anzeige gedacht (deutsch).</summary>
public static bool TryParse(string? text, [NotNullWhen(true)] out RecurrenceRule? rule, out string? error)
{
rule = null;
error = null;
if (string.IsNullOrWhiteSpace(text))
{
error = "Bitte eine Wiederholungsregel angeben.";
return false;
}
var value = text.Trim();
if (value.Length > MaxLength)
{
error = "Die Wiederholungsregel ist zu lang.";
return false;
}
if (value.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase))
{
value = value[6..];
}
var parts = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var part in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separator = part.IndexOf('=', StringComparison.Ordinal);
if (separator <= 0 || separator == part.Length - 1)
{
error = $"Ungültiger Teil „{part}“ in der Wiederholungsregel.";
return false;
}
if (!parts.TryAdd(part[..separator].Trim().ToUpperInvariant(), part[(separator + 1)..].Trim().ToUpperInvariant()))
{
error = $"„{part[..separator]}“ ist mehrfach angegeben.";
return false;
}
}
if (!parts.TryGetValue("FREQ", out var freq))
{
error = "Die Wiederholungsregel braucht eine Häufigkeit (FREQ).";
return false;
}
RecurrenceFrequency frequency;
switch (freq)
{
case "DAILY": frequency = RecurrenceFrequency.Daily; break;
case "WEEKLY": frequency = RecurrenceFrequency.Weekly; break;
case "MONTHLY": frequency = RecurrenceFrequency.Monthly; break;
case "YEARLY": frequency = RecurrenceFrequency.Yearly; break;
default:
error = "Unterstützt werden tägliche, wöchentliche, monatliche und jährliche Wiederholungen.";
return false;
}
var result = new RecurrenceRule { Frequency = frequency };
foreach (var (key, raw) in parts)
{
switch (key)
{
case "FREQ":
break;
case "INTERVAL":
if (!TryInt(raw, 1, MaxInterval, out var interval))
{
error = $"Das Intervall muss zwischen 1 und {MaxInterval} liegen.";
return false;
}
result = result with { Interval = interval };
break;
case "COUNT":
if (!TryInt(raw, 1, MaxCount, out var count))
{
error = $"Die Anzahl muss zwischen 1 und {MaxCount} liegen.";
return false;
}
result = result with { Count = count };
break;
case "UNTIL":
if (!TryParseUntil(raw, out var untilDate, out var untilUtc))
{
error = "Das Enddatum (UNTIL) ist ungültig.";
return false;
}
result = result with { UntilDate = untilDate, UntilUtc = untilUtc };
break;
case "BYDAY":
var days = new List<WeekdayNum>();
foreach (var item in raw.Split(','))
{
if (!TryParseWeekday(item, out var day))
{
error = $"Unbekannter Wochentag „{item}“.";
return false;
}
if (day.Ordinal != 0 && frequency is not (RecurrenceFrequency.Monthly or RecurrenceFrequency.Yearly))
{
error = "Wochentage mit Nummer (z. B. 2MO) gibt es nur bei monatlicher oder jährlicher Wiederholung.";
return false;
}
if (!days.Contains(day))
{
days.Add(day);
}
}
result = result with { ByDay = days };
break;
case "BYMONTHDAY":
if (frequency == RecurrenceFrequency.Weekly)
{
error = "Monatstage passen nicht zu einer wöchentlichen Wiederholung.";
return false;
}
if (!TryIntList(raw, -31, 31, out var monthDays) || monthDays.Contains(0))
{
error = "Monatstage müssen zwischen 1 und 31 (bzw. -1 und -31 vom Monatsende) liegen.";
return false;
}
result = result with { ByMonthDay = monthDays };
break;
case "BYMONTH":
if (!TryIntList(raw, 1, 12, out var months))
{
error = "Monate müssen zwischen 1 und 12 liegen.";
return false;
}
result = result with { ByMonth = months };
break;
case "WKST":
if (raw != "MO")
{
error = "Als Wochenbeginn wird nur Montag (WKST=MO) unterstützt.";
return false;
}
break;
default:
error = $"„{key}“ wird in Wiederholungsregeln nicht unterstützt.";
return false;
}
}
if (result.Count is not null && (result.UntilDate is not null || result.UntilUtc is not null))
{
error = "Anzahl (COUNT) und Enddatum (UNTIL) dürfen nicht gemeinsam angegeben werden.";
return false;
}
rule = result;
return true;
}
/// <summary>Kanonische RRULE ohne Präfix, z. B. <c>FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=10</c>.</summary>
public override string ToString()
{
var builder = new StringBuilder("FREQ=").Append(Frequency.ToString().ToUpperInvariant());
if (Interval != 1)
{
builder.Append(";INTERVAL=").Append(Interval.ToString(CultureInfo.InvariantCulture));
}
if (ByMonth.Count > 0)
{
builder.Append(";BYMONTH=").AppendJoin(',', ByMonth.Select(m => m.ToString(CultureInfo.InvariantCulture)));
}
if (ByMonthDay.Count > 0)
{
builder.Append(";BYMONTHDAY=").AppendJoin(',', ByMonthDay.Select(d => d.ToString(CultureInfo.InvariantCulture)));
}
if (ByDay.Count > 0)
{
builder.Append(";BYDAY=").AppendJoin(',', ByDay.Select(d => d.ToString()));
}
if (Count is { } count)
{
builder.Append(";COUNT=").Append(count.ToString(CultureInfo.InvariantCulture));
}
else if (UntilUtc is { } untilUtc)
{
builder.Append(";UNTIL=").Append(untilUtc.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture));
}
else if (UntilDate is { } untilDate)
{
builder.Append(";UNTIL=").Append(untilDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture));
}
return builder.ToString();
}
/// <summary>Kurze deutsche Beschreibung, z. B. „Alle 2 Wochen am Mo, Mi, 10-mal“.</summary>
public string Describe()
{
var text = (Frequency, Interval) switch
{
(RecurrenceFrequency.Daily, 1) => "Täglich",
(RecurrenceFrequency.Daily, var n) => $"Alle {n} Tage",
(RecurrenceFrequency.Weekly, 1) => "Wöchentlich",
(RecurrenceFrequency.Weekly, var n) => $"Alle {n} Wochen",
(RecurrenceFrequency.Monthly, 1) => "Monatlich",
(RecurrenceFrequency.Monthly, var n) => $"Alle {n} Monate",
(RecurrenceFrequency.Yearly, 1) => "Jährlich",
(_, var n) => $"Alle {n} Jahre",
};
if (ByMonth.Count > 0)
{
text += " im " + string.Join(", ", ByMonth.Select(m => German.DateTimeFormat.GetMonthName(m)));
}
if (ByMonthDay.Count > 0)
{
text += " am " + string.Join(", ", ByMonthDay.Select(d => d > 0 ? $"{d}." : d == -1 ? "letzten Tag" : $"{-d}.-letzten Tag"));
}
if (ByDay.Count > 0)
{
text += " am " + string.Join(", ", ByDay.Select(DescribeDay));
}
if (Count is { } count)
{
text += count == 1 ? ", einmal" : $", {count}-mal";
}
else if (UntilDate is { } untilDate)
{
text += $" bis {untilDate.ToString("dd.MM.yyyy", German)}";
}
else if (UntilUtc is { } untilUtc)
{
text += $" bis {untilUtc.ToString("dd.MM.yyyy", German)}";
}
return text;
}
internal static string DayCode(DayOfWeek day) => DayCodes[(int)day];
private static string DescribeDay(WeekdayNum day) => day.Ordinal switch
{
0 => GermanDays[(int)day.Day],
-1 => $"letzten {GermanDays[(int)day.Day]}",
< 0 => $"{-day.Ordinal}.-letzten {GermanDays[(int)day.Day]}",
_ => $"{day.Ordinal}. {GermanDays[(int)day.Day]}",
};
private static bool TryInt(string raw, int min, int max, out int value) =>
int.TryParse(raw, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value) && value >= min && value <= max;
private static bool TryIntList(string raw, int min, int max, out List<int> values)
{
values = [];
foreach (var item in raw.Split(','))
{
if (!TryInt(item.Trim(), min, max, out var value))
{
return false;
}
if (!values.Contains(value))
{
values.Add(value);
}
}
return values.Count > 0;
}
private static bool TryParseWeekday(string raw, out WeekdayNum day)
{
day = default;
var item = raw.Trim();
if (item.Length < 2)
{
return false;
}
var index = Array.IndexOf(DayCodes, item[^2..]);
if (index < 0)
{
return false;
}
var ordinal = 0;
var prefix = item[..^2];
if (prefix.Length > 0 && (!TryInt(prefix, -53, 53, out ordinal) || ordinal == 0))
{
return false;
}
day = new WeekdayNum((DayOfWeek)index, ordinal);
return true;
}
private static bool TryParseUntil(string raw, out DateOnly? date, out DateTimeOffset? utc)
{
date = null;
utc = null;
if (raw.Length == 8 && DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d))
{
date = d;
return true;
}
if (raw.Length == 16 && raw.EndsWith('Z')
&& DateTime.TryParseExact(raw, "yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var u))
{
utc = new DateTimeOffset(u, TimeSpan.Zero);
return true;
}
return false;
}
}
@@ -0,0 +1,232 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Tasks;
/// <summary>Status einer Aufgabe. <see cref="AwaitingApproval"/>: von einem Kind erledigt, wartet auf Eltern-Bestätigung.</summary>
public enum TodoStatus
{
Open = 0,
InProgress = 1,
AwaitingApproval = 2,
Done = 3,
}
public enum TodoPriority
{
Low = 0,
Normal = 1,
High = 2,
}
/// <summary>Bezugspunkt der nächsten Wiederholung: fester Rhythmus ab Fälligkeit oder Abstand ab Erledigung.</summary>
public enum RecurrenceMode
{
FromDueDate = 0,
FromCompletion = 1,
}
public enum TaskHistoryAction
{
Created = 0,
Updated = 1,
StatusChanged = 2,
Approved = 3,
Rejected = 4,
Deleted = 5,
}
/// <summary>
/// Gemeinsame Sicht auf eine Aufgabe für Regeln, die in Api (Entität) und Client (DTO) gleich gelten:
/// Sichtbarkeit, Rechte, Ansichten und Filter.
/// </summary>
public interface ITaskFacts
{
Guid Id { get; }
string Title { get; }
Guid? CreatedBy { get; }
DateTimeOffset CreatedAt { get; }
IReadOnlyList<Guid> AssigneeIds { get; }
bool AssignedToFamily { get; }
bool IsPrivate { get; }
bool IsChore { get; }
int Points { get; }
bool NeedsApproval { get; }
TodoStatus Status { get; }
TodoPriority Priority { get; }
string? Category { get; }
/// <summary>Fälligkeitstag in der Familienzeitzone.</summary>
DateOnly? DueDate { get; }
/// <summary>Optionale Uhrzeit (Familienzeitzone); ohne Uhrzeit ist die Aufgabe „am Tag“ fällig.</summary>
TimeOnly? DueTime { get; }
/// <summary>Fälligkeit als UTC (bei Aufgaben ohne Uhrzeit: Tagesbeginn).</summary>
DateTimeOffset? DueAt { get; }
DateTimeOffset? CompletedAt { get; }
}
/// <summary>Eine Aufgabe oder ein Ämtchen (ADR-0014). Die Id kann vom Client stammen (Offline-Erfassung).</summary>
public sealed class TodoTask : AuditableEntity, ISoftDeletable, IHasRowVersion, ITaskFacts
{
public const int TitleMaxLength = 200;
public const int NotesMaxLength = 2000;
public const int CategoryMaxLength = 40;
public const int MaxPoints = 100;
public const int MaxAssignees = 20;
public const int MaxChecklistItems = 30;
public const int ChecklistTextMaxLength = 200;
public const int MaxReminderMinutes = 7 * 24 * 60;
public string Title { get; set; } = string.Empty;
public string? Notes { get; set; }
public DateOnly? DueDate { get; set; }
public TimeOnly? DueTime { get; set; }
public DateTimeOffset? DueAt { get; set; }
/// <summary>Erinnerung so viele Minuten vor der Fälligkeit (ohne Uhrzeit: vor 09:00 Uhr am Fälligkeitstag).</summary>
public int? ReminderMinutesBefore { get; set; }
public DateTimeOffset? RemindAt { get; set; }
public DateTimeOffset? ReminderSentAt { get; set; }
public TodoPriority Priority { get; set; } = TodoPriority.Normal;
public TodoStatus Status { get; set; } = TodoStatus.Open;
public string? Category { get; set; }
/// <summary>Nur für Ersteller und zugewiesene Personen sichtbar (serverseitig erzwungen).</summary>
public bool IsPrivate { get; set; }
public bool IsChore { get; set; }
public int Points { get; set; }
/// <summary>Erledigung durch ein Kind muss von Eltern bestätigt werden (zusätzlich zur globalen Einstellung).</summary>
public bool NeedsApproval { get; set; }
/// <summary>Für die ganze Familie: jedes Familienmitglied (ohne Gäste) darf sie erledigen.</summary>
public bool AssignedToFamily { get; set; }
public List<TaskAssignment> Assignments { get; set; } = [];
public List<TaskChecklistItem> Checklist { get; set; } = [];
/// <summary>RRULE (Teilmenge, ADR-0014) ohne Präfix; <c>null</c> = einmalig.</summary>
public string? RecurrenceRule { get; set; }
public RecurrenceMode RecurrenceMode { get; set; } = RecurrenceMode.FromDueDate;
/// <summary>Gemeinsame Id aller Instanzen einer wiederkehrenden Aufgabe.</summary>
public Guid? SeriesId { get; set; }
/// <summary>Erster Fälligkeitstag der Serie (DTSTART) – Bezug für COUNT/BYDAY usw.</summary>
public DateOnly? SeriesStart { get; set; }
/// <summary>Laufende Nummer der Instanz in der Serie (1 = erste).</summary>
public int SeriesIndex { get; set; } = 1;
public Guid? NextInstanceId { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public Guid? CompletedBy { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public Guid? ApprovedBy { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
public IReadOnlyList<Guid> AssigneeIds => Assignments.Select(a => a.UserId).ToList();
public bool IsRecurring => RecurrenceRule is not null;
public bool IsFinished => Status is TodoStatus.Done or TodoStatus.AwaitingApproval;
/// <summary>Zuweisungen ersetzen (Duplikate werden entfernt).</summary>
public void SetAssignees(IEnumerable<Guid> userIds)
{
var wanted = userIds.Distinct().ToList();
Assignments.RemoveAll(a => !wanted.Contains(a.UserId));
foreach (var userId in wanted.Where(id => Assignments.All(a => a.UserId != id)))
{
Assignments.Add(new TaskAssignment { TaskId = Id, UserId = userId });
}
}
}
/// <summary>Zuweisung einer Aufgabe an eine Person.</summary>
public sealed class TaskAssignment : AuditableEntity
{
public Guid TaskId { get; set; }
public Guid UserId { get; set; }
}
/// <summary>Punkt einer Checkliste (Unteraufgabe).</summary>
public sealed class TaskChecklistItem : AuditableEntity
{
public Guid TaskId { get; set; }
public string Text { get; set; } = string.Empty;
public bool IsDone { get; set; }
public int SortOrder { get; set; }
}
/// <summary>Verlauf einer Aufgabe: wer hat wann was geändert (<see cref="AuditableEntity.CreatedBy"/>/<see cref="AuditableEntity.CreatedAt"/>).</summary>
public sealed class TaskHistoryEntry : AuditableEntity
{
public const int NoteMaxLength = 300;
public Guid TaskId { get; set; }
public TaskHistoryAction Action { get; set; }
public TodoStatus? FromStatus { get; set; }
public TodoStatus? ToStatus { get; set; }
public string? Note { get; set; }
}
/// <summary>Punktebuchung (Gutschrift für ein Ämtchen, Rückbuchung, manuelle Korrektur). Der Punktestand ist die Summe.</summary>
public sealed class PointsLedgerEntry : AuditableEntity
{
public const int ReasonMaxLength = 200;
public Guid UserId { get; set; }
public int Points { get; set; }
public Guid? TaskId { get; set; }
public string Reason { get; set; } = string.Empty;
}
+126
View File
@@ -0,0 +1,126 @@
using FamilyPlanner.Domain.Authorization;
namespace FamilyPlanner.Domain.Tasks;
/// <summary>
/// Der handelnde Benutzer mit seinen wirksamen Aufgaben-Rechten (Rollenmatrix, ADR-0008). Wird in Api und Client
/// gleich gebildet, damit objektbezogene Regeln (<see cref="TaskRules"/>) überall identisch gelten.
/// </summary>
public sealed record TaskActor(
Guid UserId,
AccessLevel Read,
AccessLevel Edit,
bool CanAssign,
AccessLevel Complete,
bool CanApprove,
bool IsChild,
bool IsGuest)
{
public static TaskActor From(Guid userId, IEnumerable<string> roles, PermissionOptions? options = null)
{
var list = roles.ToList();
var adult = list.Contains(Roles.Admin) || list.Contains(Roles.Family);
return new TaskActor(
userId,
PermissionMatrix.For(Permission.TasksRead, list, options),
PermissionMatrix.For(Permission.TasksEdit, list, options),
PermissionMatrix.For(Permission.TasksAssign, list, options) == AccessLevel.Full,
PermissionMatrix.For(Permission.TasksCompleteAssigned, list, options),
PermissionMatrix.For(Permission.TasksApprove, list, options) == AccessLevel.Full,
IsChild: !adult && list.Contains(Roles.Child),
IsGuest: !adult && !list.Contains(Roles.Child) && list.Contains(Roles.Guest));
}
public bool IsParent => CanApprove;
}
/// <summary>Objektbezogene Regeln für Aufgaben (ergänzen die Rollenmatrix, ADR-0008/ADR-0014).</summary>
public static class TaskRules
{
/// <summary>Ersteller oder zugewiesene Person.</summary>
public static bool IsInvolved(ITaskFacts task, Guid userId) =>
task.CreatedBy == userId || task.AssigneeIds.Contains(userId);
/// <summary>
/// Sichtbarkeit: private Aufgaben nur für Ersteller/Zugewiesene (auch nicht für Admins). Sonst Familie/Admin alles,
/// Kinder eigene + zugewiesene + Familienaufgaben, Gäste nur ihnen zugewiesene.
/// </summary>
public static bool CanSee(ITaskFacts task, TaskActor actor)
{
if (task.IsPrivate)
{
return IsInvolved(task, actor.UserId) && actor.Read != AccessLevel.None;
}
return actor.Read switch
{
AccessLevel.Full => true,
AccessLevel.Restricted when actor.IsGuest => task.AssigneeIds.Contains(actor.UserId),
AccessLevel.Restricted => IsInvolved(task, actor.UserId) || task.AssignedToFamily,
_ => false,
};
}
/// <summary>Bearbeiten/Löschen: Familie/Admin jede sichtbare Aufgabe, Kinder nur selbst angelegte.</summary>
public static bool CanEdit(ITaskFacts task, TaskActor actor) =>
CanSee(task, actor) && actor.Edit switch
{
AccessLevel.Full => true,
AccessLevel.Restricted => task.CreatedBy == actor.UserId,
_ => false,
};
/// <summary>
/// Status ändern (offen/in Arbeit/erledigt): Familie/Admin jede sichtbare Aufgabe, sonst eigene, zugewiesene und –
/// außer für Gäste – Familienaufgaben.
/// </summary>
public static bool CanChangeStatus(ITaskFacts task, TaskActor actor)
{
if (!CanSee(task, actor) || actor.Complete == AccessLevel.None)
{
return false;
}
if (actor.Edit == AccessLevel.Full)
{
return true;
}
return IsInvolved(task, actor.UserId) || (task.AssignedToFamily && !actor.IsGuest);
}
public static bool CanApprove(ITaskFacts task, TaskActor actor) =>
actor.CanApprove && CanSee(task, actor) && task.Status == TodoStatus.AwaitingApproval;
/// <summary>
/// Prüft Zuweisung und Ämtchen-Felder für Anlegen/Ändern. Ohne <c>TasksAssign</c> (Kinder) nur für sich selbst,
/// keine Familienaufgaben, keine Ämtchen/Punkte. Liefert eine Fehlermeldung oder <c>null</c>.
/// </summary>
public static string? CheckAssignment(TaskActor actor, IReadOnlyCollection<Guid> assigneeIds, bool assignedToFamily, bool isChore, int points)
{
if (actor.Edit == AccessLevel.None)
{
return "Du darfst keine Aufgaben anlegen oder ändern.";
}
if (actor.CanAssign)
{
return null;
}
if (assignedToFamily || assigneeIds.Any(id => id != actor.UserId))
{
return "Du darfst Aufgaben nur dir selbst zuweisen.";
}
if (isChore || points != 0)
{
return "Ämtchen und Punkte vergeben nur Eltern.";
}
return null;
}
/// <summary>Kinder (TasksEdit eingeschränkt) legen nur eigene, private Aufgaben an (Rollenmatrix).</summary>
public static bool ForcePrivate(TaskActor actor) => actor.Edit == AccessLevel.Restricted;
}
@@ -0,0 +1,113 @@
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Recurrence;
namespace FamilyPlanner.Domain.Tasks;
/// <summary>Fälligkeit, Erinnerung und Wiederholung von Aufgaben (ADR-0014). Alle Berechnungen in der Familienzeitzone.</summary>
public static class TaskSchedule
{
/// <summary>Bezugszeit für Erinnerungen an Aufgaben ohne Uhrzeit.</summary>
public static readonly TimeOnly DefaultReminderTime = new(9, 0);
public static DateTimeOffset? DueAt(DateOnly? date, TimeOnly? time, TimeZoneInfo zone) =>
date is { } d ? FamilyTime.ToUtc(d.ToDateTime(time ?? TimeOnly.MinValue), zone) : null;
public static DateTimeOffset? RemindAt(DateOnly? date, TimeOnly? time, int? minutesBefore, TimeZoneInfo zone) =>
date is { } d && minutesBefore is { } minutes
? FamilyTime.ToUtc(d.ToDateTime(time ?? DefaultReminderTime), zone).AddMinutes(-minutes)
: null;
/// <summary>Fälligkeit und Erinnerung nach Änderung von Tag/Uhrzeit/Erinnerung neu berechnen.</summary>
public static void Recalculate(TodoTask task, TimeZoneInfo zone)
{
task.DueAt = DueAt(task.DueDate, task.DueTime, zone);
var remindAt = RemindAt(task.DueDate, task.DueTime, task.ReminderMinutesBefore, zone);
if (remindAt != task.RemindAt)
{
task.RemindAt = remindAt;
task.ReminderSentAt = null;
}
}
/// <summary>
/// Fälligkeitstag der nächsten Instanz oder <c>null</c>, wenn die Serie endet (COUNT/UNTIL) bzw. keine Regel/kein Datum vorliegt.
/// <list type="bullet">
/// <item><see cref="RecurrenceMode.FromDueDate"/>: nächstes Vorkommen nach der bisherigen Fälligkeit; verpasste Termine in der
/// Vergangenheit werden übersprungen (frühestens heute bzw. – mit Uhrzeit – nach „jetzt“).</item>
/// <item><see cref="RecurrenceMode.FromCompletion"/>: ein Intervall nach dem Erledigungstag (COUNT zählt die Instanzen).</item>
/// </list>
/// </summary>
public static DateOnly? NextDueDate(TodoTask task, DateTimeOffset completedAt, DateTimeOffset now, TimeZoneInfo zone)
{
if (task.DueDate is not { } due || !RecurrenceRule.TryParse(task.RecurrenceRule, out var rule, out _))
{
return null;
}
var time = task.DueTime ?? TimeOnly.MinValue;
var current = due.ToDateTime(time);
if (task.RecurrenceMode == RecurrenceMode.FromCompletion)
{
if (rule.Count is { } count && task.SeriesIndex >= count)
{
return null;
}
var completedOn = FamilyTime.LocalDate(completedAt, zone).ToDateTime(time);
var next = RecurrenceEvaluator.NextAfter(rule with { Count = null }, completedOn, completedOn, zone);
return next is { } n ? DateOnly.FromDateTime(n) : null;
}
var start = (task.SeriesStart ?? due).ToDateTime(time);
var nowLocal = FamilyTime.ToLocal(now, zone);
var today = DateOnly.FromDateTime(nowLocal);
foreach (var occurrence in RecurrenceEvaluator.Occurrences(rule, start, zone))
{
if (occurrence <= current)
{
continue;
}
var isFuture = task.DueTime is null ? DateOnly.FromDateTime(occurrence) >= today : occurrence > nowLocal;
if (isFuture)
{
return DateOnly.FromDateTime(occurrence);
}
}
return null;
}
/// <summary>Neue, offene Instanz derselben Serie mit gleichem Inhalt (Checkliste zurückgesetzt) und neuer Fälligkeit.</summary>
public static TodoTask CreateNextInstance(TodoTask task, DateOnly nextDue, TimeZoneInfo zone)
{
var next = new TodoTask
{
Title = task.Title,
Notes = task.Notes,
DueDate = nextDue,
DueTime = task.DueTime,
ReminderMinutesBefore = task.ReminderMinutesBefore,
Priority = task.Priority,
Category = task.Category,
IsPrivate = task.IsPrivate,
IsChore = task.IsChore,
Points = task.Points,
NeedsApproval = task.NeedsApproval,
AssignedToFamily = task.AssignedToFamily,
RecurrenceRule = task.RecurrenceRule,
RecurrenceMode = task.RecurrenceMode,
SeriesId = task.SeriesId ?? task.Id,
SeriesStart = task.SeriesStart ?? task.DueDate,
SeriesIndex = task.SeriesIndex + 1,
};
next.SetAssignees(task.AssigneeIds);
next.Checklist = task.Checklist
.OrderBy(c => c.SortOrder)
.Select((c, i) => new TaskChecklistItem { TaskId = next.Id, Text = c.Text, SortOrder = i })
.ToList();
Recalculate(next, zone);
return next;
}
}
+139
View File
@@ -0,0 +1,139 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Tasks;
/// <summary>Ansichten der Aufgabenliste.</summary>
public enum TaskView
{
/// <summary>Mir zugewiesen oder von mir angelegt und niemandem zugewiesen.</summary>
Mine,
/// <summary>Alle für mich sichtbaren Aufgaben (Familienüberblick).</summary>
Family,
/// <summary>Alle sichtbaren Aufgaben, gruppiert nach Person.</summary>
ByPerson,
/// <summary>Nicht erledigt und Fälligkeit überschritten.</summary>
Overdue,
/// <summary>Nicht erledigt und fällig in der aktuellen Kalenderwoche (Mo–So).</summary>
ThisWeek,
}
public enum TaskSort
{
DueDate,
Priority,
Created,
Title,
}
/// <summary>Optionale Filter zusätzlich zur Ansicht.</summary>
public sealed record TaskFilter(
string? Category = null,
Guid? PersonId = null,
TodoPriority? Priority = null,
bool ChoresOnly = false,
bool IncludeDone = false);
/// <summary>Ansichten, Filter und Sortierung (Familienzeitzone, ADR-0014) – identisch in Api-Tests und Client.</summary>
public static class TaskViews
{
public static bool IsOpen(ITaskFacts task) => task.Status is TodoStatus.Open or TodoStatus.InProgress;
/// <summary>Überfällig: offen und – mit Uhrzeit – Zeitpunkt vorbei bzw. – ohne Uhrzeit – Tag vor heute.</summary>
public static bool IsOverdue(ITaskFacts task, DateTimeOffset now, TimeZoneInfo zone)
{
if (!IsOpen(task) || task.DueDate is not { } due)
{
return false;
}
return task.DueTime is not null && task.DueAt is { } dueAt
? dueAt < now
: due < FamilyTime.LocalDate(now, zone);
}
/// <summary>Offen und fällig in der Woche (Mo–So) von heute.</summary>
public static bool IsDueThisWeek(ITaskFacts task, DateTimeOffset now, TimeZoneInfo zone)
{
if (!IsOpen(task) || task.DueDate is not { } due)
{
return false;
}
var monday = FamilyTime.StartOfWeek(FamilyTime.LocalDate(now, zone));
return due >= monday && due <= monday.AddDays(6);
}
public static bool IsMine(ITaskFacts task, Guid userId) =>
task.AssigneeIds.Contains(userId) || (task.CreatedBy == userId && task.AssigneeIds.Count == 0 && !task.AssignedToFamily);
/// <summary>Für die Personenansicht/-filter: Ist die Aufgabe dieser Person zugeordnet (zugewiesen oder eigene ohne Zuweisung)?</summary>
public static bool BelongsTo(ITaskFacts task, Guid personId) => IsMine(task, personId);
public static bool Matches(ITaskFacts task, TaskView view, Guid userId, DateTimeOffset now, TimeZoneInfo zone) => view switch
{
TaskView.Mine => IsMine(task, userId),
TaskView.Overdue => IsOverdue(task, now, zone),
TaskView.ThisWeek => IsDueThisWeek(task, now, zone),
_ => true,
};
public static bool Matches(ITaskFacts task, TaskFilter filter)
{
if (!filter.IncludeDone && task.Status == TodoStatus.Done)
{
return false;
}
if (filter.Category is { } category && !string.Equals(task.Category, category, StringComparison.Ordinal))
{
return false;
}
if (filter.PersonId is { } person && !BelongsTo(task, person))
{
return false;
}
if (filter.Priority is { } priority && task.Priority != priority)
{
return false;
}
return !filter.ChoresOnly || task.IsChore;
}
public static IEnumerable<T> Apply<T>(IEnumerable<T> tasks, TaskView view, TaskFilter filter, Guid userId, DateTimeOffset now, TimeZoneInfo zone)
where T : ITaskFacts =>
tasks.Where(t => Matches(t, view, userId, now, zone) && Matches(t, filter));
/// <summary>
/// Sortierung: Erledigte immer unten (zuletzt erledigte zuerst), wartende vor erledigten; innerhalb der offenen nach
/// gewähltem Kriterium, Aufgaben ohne Fälligkeit hinter datierten.
/// </summary>
public static IEnumerable<T> Sort<T>(IEnumerable<T> tasks, TaskSort sort)
where T : ITaskFacts
{
var ordered = tasks
.OrderBy(t => t.Status switch
{
TodoStatus.Done => 2,
TodoStatus.AwaitingApproval => 1,
_ => 0,
})
.ThenByDescending(t => t.Status == TodoStatus.Done ? t.CompletedAt : null);
ordered = sort switch
{
TaskSort.Priority => ordered.ThenByDescending(t => t.Priority).ThenBy(t => t.DueAt is null).ThenBy(t => t.DueAt),
TaskSort.Created => ordered.ThenByDescending(t => t.CreatedAt),
TaskSort.Title => ordered.ThenBy(t => t.Title, StringComparer.CurrentCultureIgnoreCase),
_ => ordered.ThenBy(t => t.DueAt is null).ThenBy(t => t.DueAt).ThenByDescending(t => t.Priority),
};
return ordered.ThenBy(t => t.CreatedAt);
}
}
@@ -0,0 +1,120 @@
namespace FamilyPlanner.Domain.Tasks;
/// <summary>Ergebnis einer Statusänderung (für Verlauf, Punkte und Wiederholung).</summary>
public sealed record StatusTransition(TodoStatus From, TodoStatus To)
{
public bool IsChange => From != To;
/// <summary>Endgültig erledigt (ggf. nach Bestätigung): Punkte gutschreiben, nächste Instanz erzeugen.</summary>
public bool Completes => To == TodoStatus.Done && From != TodoStatus.Done;
/// <summary>Eine erledigte Aufgabe wurde wieder geöffnet: gutgeschriebene Punkte zurückbuchen.</summary>
public bool ReopensDone => From == TodoStatus.Done && To != TodoStatus.Done;
}
/// <summary>Statusübergänge, Eltern-Bestätigung und Punkte (ADR-0014).</summary>
public static class TaskWorkflow
{
/// <summary>Statuswerte, die ein Client direkt anfordern darf (<see cref="TodoStatus.AwaitingApproval"/> entsteht nur durch Erledigen).</summary>
public static bool IsRequestable(TodoStatus status) => status != TodoStatus.AwaitingApproval;
/// <summary>
/// Braucht die Erledigung eine Eltern-Bestätigung? Nur wenn ein Kind ein Ämtchen bzw. eine Aufgabe mit Punkten erledigt und
/// die Einstellung „Eltern-Bestätigung“ aktiv ist oder die Aufgabe sie ausdrücklich verlangt.
/// </summary>
public static bool RequiresApproval(ITaskFacts task, TaskActor actor, bool settingsRequireApproval) =>
actor.IsChild && (task.IsChore || task.Points > 0) && (settingsRequireApproval || task.NeedsApproval);
/// <summary>
/// Zielstatus für eine angeforderte Änderung. Zustandsbasiert und damit idempotent: „erledigt“ bei einer bereits erledigten
/// oder auf Bestätigung wartenden Aufgabe ändert nichts.
/// </summary>
public static TodoStatus Resolve(ITaskFacts task, TodoStatus requested, TaskActor actor, bool settingsRequireApproval)
{
if (requested is TodoStatus.Done or TodoStatus.AwaitingApproval)
{
if (task.Status is TodoStatus.Done)
{
return TodoStatus.Done;
}
if (task.Status is TodoStatus.AwaitingApproval && !actor.CanApprove)
{
return TodoStatus.AwaitingApproval;
}
return RequiresApproval(task, actor, settingsRequireApproval) ? TodoStatus.AwaitingApproval : TodoStatus.Done;
}
return requested;
}
/// <summary>Setzt den Status inklusive Erledigt-/Bestätigungsfeldern.</summary>
public static StatusTransition Apply(TodoTask task, TodoStatus target, Guid actorId, DateTimeOffset now)
{
var transition = new StatusTransition(task.Status, target);
if (!transition.IsChange)
{
return transition;
}
task.Status = target;
switch (target)
{
case TodoStatus.AwaitingApproval:
task.CompletedAt = now;
task.CompletedBy = actorId;
task.ApprovedAt = null;
task.ApprovedBy = null;
break;
case TodoStatus.Done when transition.From == TodoStatus.AwaitingApproval:
task.ApprovedAt = now;
task.ApprovedBy = actorId;
break;
case TodoStatus.Done:
task.CompletedAt = now;
task.CompletedBy = actorId;
break;
default:
task.CompletedAt = null;
task.CompletedBy = null;
task.ApprovedAt = null;
task.ApprovedBy = null;
break;
}
return transition;
}
/// <summary>Eltern bestätigen: wartend → erledigt.</summary>
public static StatusTransition Approve(TodoTask task, Guid parentId, DateTimeOffset now)
{
EnsureAwaiting(task);
return Apply(task, TodoStatus.Done, parentId, now);
}
/// <summary>Eltern lehnen ab: wartend → offen (noch einmal machen).</summary>
public static StatusTransition Reject(TodoTask task, Guid parentId, DateTimeOffset now)
{
EnsureAwaiting(task);
return Apply(task, TodoStatus.Open, parentId, now);
}
/// <summary>Gibt es für diese Aufgabe Punkte? Nur Ämtchen mit Punkten und nur bei aktivem Punktesystem.</summary>
public static bool AwardsPoints(ITaskFacts task, bool pointsEnabled) => pointsEnabled && task.IsChore && task.Points > 0;
/// <summary>
/// Wer bekommt die Punkte? Wer erledigt hat, wenn er zugewiesen ist oder es eine Familien-/eigene Aufgabe ist; hat jemand
/// anderes für genau eine zugewiesene Person abgehakt (z. B. Eltern für ihr Kind), bekommt diese Person die Punkte.
/// </summary>
public static Guid PointsRecipient(ITaskFacts task, Guid completedBy) =>
task.AssigneeIds.Contains(completedBy) || task.AssigneeIds.Count != 1 ? completedBy : task.AssigneeIds[0];
private static void EnsureAwaiting(TodoTask task)
{
if (task.Status != TodoStatus.AwaitingApproval)
{
throw new InvalidOperationException("Diese Aufgabe wartet nicht auf eine Bestätigung.");
}
}
}
@@ -1,7 +1,9 @@
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Push;
using FamilyPlanner.Domain.Settings;
using FamilyPlanner.Domain.Shopping;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Domain.Users;
using FamilyPlanner.Infrastructure.Identity;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
@@ -31,6 +33,18 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<MealWish> MealWishes => Set<MealWish>();
public DbSet<TodoTask> Tasks => Set<TodoTask>();
public DbSet<TaskAssignment> TaskAssignments => Set<TaskAssignment>();
public DbSet<TaskChecklistItem> TaskChecklistItems => Set<TaskChecklistItem>();
public DbSet<TaskHistoryEntry> TaskHistory => Set<TaskHistoryEntry>();
public DbSet<PointsLedgerEntry> PointsLedger => Set<PointsLedgerEntry>();
public DbSet<PushSubscription> PushSubscriptions => Set<PushSubscription>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -0,0 +1,91 @@
using FamilyPlanner.Domain.Push;
using FamilyPlanner.Domain.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class TodoTaskConfiguration : IEntityTypeConfiguration<TodoTask>
{
public void Configure(EntityTypeBuilder<TodoTask> builder)
{
builder.ToTable("Tasks");
builder.Property(t => t.Title).HasMaxLength(TodoTask.TitleMaxLength).IsRequired();
builder.Property(t => t.Notes).HasMaxLength(TodoTask.NotesMaxLength);
builder.Property(t => t.Category).HasMaxLength(TodoTask.CategoryMaxLength);
builder.Property(t => t.Priority).HasConversion<string>().HasMaxLength(20);
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20);
builder.Property(t => t.RecurrenceMode).HasConversion<string>().HasMaxLength(20);
builder.Property(t => t.RecurrenceRule).HasMaxLength(Domain.Recurrence.RecurrenceRule.MaxLength);
builder.Property(t => t.RowVersion).IsRowVersion();
builder.Ignore(t => t.AssigneeIds);
builder.Ignore(t => t.IsRecurring);
builder.Ignore(t => t.IsFinished);
builder.HasMany(t => t.Assignments).WithOne().HasForeignKey(a => a.TaskId).OnDelete(DeleteBehavior.Cascade);
builder.HasMany(t => t.Checklist).WithOne().HasForeignKey(c => c.TaskId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(t => new { t.Status, t.DueAt });
builder.HasIndex(t => new { t.RemindAt, t.ReminderSentAt });
builder.HasIndex(t => t.SeriesId);
}
}
internal sealed class TaskAssignmentConfiguration : IEntityTypeConfiguration<TaskAssignment>
{
public void Configure(EntityTypeBuilder<TaskAssignment> builder)
{
builder.ToTable("TaskAssignments");
builder.HasIndex(a => new { a.TaskId, a.UserId }).IsUnique();
builder.HasIndex(a => a.UserId);
}
}
internal sealed class TaskChecklistItemConfiguration : IEntityTypeConfiguration<TaskChecklistItem>
{
public void Configure(EntityTypeBuilder<TaskChecklistItem> builder)
{
builder.ToTable("TaskChecklistItems");
builder.Property(c => c.Text).HasMaxLength(TodoTask.ChecklistTextMaxLength).IsRequired();
builder.HasIndex(c => new { c.TaskId, c.SortOrder });
}
}
internal sealed class TaskHistoryEntryConfiguration : IEntityTypeConfiguration<TaskHistoryEntry>
{
public void Configure(EntityTypeBuilder<TaskHistoryEntry> builder)
{
builder.ToTable("TaskHistory");
builder.Property(h => h.Action).HasConversion<string>().HasMaxLength(20);
builder.Property(h => h.FromStatus).HasConversion<string>().HasMaxLength(20);
builder.Property(h => h.ToStatus).HasConversion<string>().HasMaxLength(20);
builder.Property(h => h.Note).HasMaxLength(TaskHistoryEntry.NoteMaxLength);
builder.HasIndex(h => new { h.TaskId, h.CreatedAt });
}
}
internal sealed class PointsLedgerEntryConfiguration : IEntityTypeConfiguration<PointsLedgerEntry>
{
public void Configure(EntityTypeBuilder<PointsLedgerEntry> builder)
{
builder.ToTable("PointsLedger");
builder.Property(p => p.Reason).HasMaxLength(PointsLedgerEntry.ReasonMaxLength).IsRequired();
builder.HasIndex(p => p.UserId);
builder.HasIndex(p => p.TaskId);
}
}
internal sealed class PushSubscriptionConfiguration : IEntityTypeConfiguration<PushSubscription>
{
public void Configure(EntityTypeBuilder<PushSubscription> builder)
{
builder.ToTable("PushSubscriptions");
builder.Property(s => s.Endpoint).HasMaxLength(PushSubscription.EndpointMaxLength).IsRequired();
builder.Property(s => s.EndpointHash).HasMaxLength(64).IsRequired();
builder.Property(s => s.P256dh).HasMaxLength(PushSubscription.KeyMaxLength).IsRequired();
builder.Property(s => s.Auth).HasMaxLength(PushSubscription.KeyMaxLength).IsRequired();
builder.Property(s => s.UserAgent).HasMaxLength(PushSubscription.UserAgentMaxLength);
builder.HasIndex(s => s.EndpointHash).IsUnique();
builder.HasIndex(s => s.UserId);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,254 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase5Tasks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PointsLedger",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Points = table.Column<int>(type: "int", nullable: false),
TaskId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Reason = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PointsLedger", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PushSubscriptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Endpoint = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
EndpointHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
P256dh = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Auth = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
UserAgent = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
LastSuccessAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
FailureCount = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PushSubscriptions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TaskHistory",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TaskId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Action = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
FromStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
ToStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
Note = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TaskHistory", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Notes = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
DueDate = table.Column<DateOnly>(type: "date", nullable: true),
DueTime = table.Column<TimeOnly>(type: "time", nullable: true),
DueAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ReminderMinutesBefore = table.Column<int>(type: "int", nullable: true),
RemindAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ReminderSentAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
Priority = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Category = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
IsPrivate = table.Column<bool>(type: "bit", nullable: false),
IsChore = table.Column<bool>(type: "bit", nullable: false),
Points = table.Column<int>(type: "int", nullable: false),
NeedsApproval = table.Column<bool>(type: "bit", nullable: false),
AssignedToFamily = table.Column<bool>(type: "bit", nullable: false),
RecurrenceRule = table.Column<string>(type: "nvarchar(250)", maxLength: 250, nullable: true),
RecurrenceMode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
SeriesId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
SeriesStart = table.Column<DateOnly>(type: "date", nullable: true),
SeriesIndex = table.Column<int>(type: "int", nullable: false),
NextInstanceId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CompletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
ApprovedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ApprovedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TaskAssignments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TaskId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TaskAssignments", x => x.Id);
table.ForeignKey(
name: "FK_TaskAssignments_Tasks_TaskId",
column: x => x.TaskId,
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TaskChecklistItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TaskId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Text = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
IsDone = table.Column<bool>(type: "bit", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TaskChecklistItems", x => x.Id);
table.ForeignKey(
name: "FK_TaskChecklistItems_Tasks_TaskId",
column: x => x.TaskId,
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PointsLedger_TaskId",
table: "PointsLedger",
column: "TaskId");
migrationBuilder.CreateIndex(
name: "IX_PointsLedger_UserId",
table: "PointsLedger",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_PushSubscriptions_EndpointHash",
table: "PushSubscriptions",
column: "EndpointHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PushSubscriptions_UserId",
table: "PushSubscriptions",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_TaskAssignments_TaskId_UserId",
table: "TaskAssignments",
columns: new[] { "TaskId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TaskAssignments_UserId",
table: "TaskAssignments",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_TaskChecklistItems_TaskId_SortOrder",
table: "TaskChecklistItems",
columns: new[] { "TaskId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_TaskHistory_TaskId_CreatedAt",
table: "TaskHistory",
columns: new[] { "TaskId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Tasks_IsDeleted",
table: "Tasks",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_Tasks_RemindAt_ReminderSentAt",
table: "Tasks",
columns: new[] { "RemindAt", "ReminderSentAt" });
migrationBuilder.CreateIndex(
name: "IX_Tasks_SeriesId",
table: "Tasks",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_Tasks_Status_DueAt",
table: "Tasks",
columns: new[] { "Status", "DueAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PointsLedger");
migrationBuilder.DropTable(
name: "PushSubscriptions");
migrationBuilder.DropTable(
name: "TaskAssignments");
migrationBuilder.DropTable(
name: "TaskChecklistItems");
migrationBuilder.DropTable(
name: "TaskHistory");
migrationBuilder.DropTable(
name: "Tasks");
}
}
}
@@ -264,6 +264,67 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("MealWishes", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Push.PushSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Auth")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Endpoint")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("EndpointHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("FailureCount")
.HasColumnType("int");
b.Property<DateTimeOffset?>("LastSuccessAt")
.HasColumnType("datetimeoffset");
b.Property<string>("P256dh")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserAgent")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EndpointHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("PushSubscriptions", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Settings.FamilySettings", b =>
{
b.Property<Guid>("Id")
@@ -485,6 +546,300 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("ShoppingSuggestions", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.PointsLedgerEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Points")
.HasColumnType("int");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid?>("TaskId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("TaskId");
b.HasIndex("UserId");
b.ToTable("PointsLedger", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TaskAssignment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("TaskId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("TaskId", "UserId")
.IsUnique();
b.ToTable("TaskAssignments", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TaskChecklistItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDone")
.HasColumnType("bit");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<Guid>("TaskId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("TaskId", "SortOrder");
b.ToTable("TaskChecklistItems", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TaskHistoryEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("FromStatus")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Note")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<Guid>("TaskId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ToStatus")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("TaskId", "CreatedAt");
b.ToTable("TaskHistory", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TodoTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("ApprovedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("ApprovedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("AssignedToFamily")
.HasColumnType("bit");
b.Property<string>("Category")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CompletedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DueAt")
.HasColumnType("datetimeoffset");
b.Property<DateOnly?>("DueDate")
.HasColumnType("date");
b.Property<TimeOnly?>("DueTime")
.HasColumnType("time");
b.Property<bool>("IsChore")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsPrivate")
.HasColumnType("bit");
b.Property<bool>("NeedsApproval")
.HasColumnType("bit");
b.Property<Guid?>("NextInstanceId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Notes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int>("Points")
.HasColumnType("int");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RecurrenceMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RecurrenceRule")
.HasMaxLength(250)
.HasColumnType("nvarchar(250)");
b.Property<DateTimeOffset?>("RemindAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ReminderMinutesBefore")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ReminderSentAt")
.HasColumnType("datetimeoffset");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid?>("SeriesId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SeriesIndex")
.HasColumnType("int");
b.Property<DateOnly?>("SeriesStart")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("SeriesId");
b.HasIndex("RemindAt", "ReminderSentAt");
b.HasIndex("Status", "DueAt");
b.ToTable("Tasks", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Users.UserAvatar", b =>
{
b.Property<Guid>("Id")
@@ -792,6 +1147,24 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TaskAssignment", b =>
{
b.HasOne("FamilyPlanner.Domain.Tasks.TodoTask", null)
.WithMany("Assignments")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TaskChecklistItem", b =>
{
b.HasOne("FamilyPlanner.Domain.Tasks.TodoTask", null)
.WithMany("Checklist")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Users.UserAvatar", b =>
{
b.HasOne("FamilyPlanner.Infrastructure.Identity.AppUser", null)
@@ -851,6 +1224,13 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TodoTask", b =>
{
b.Navigation("Assignments");
b.Navigation("Checklist");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,31 @@
using FamilyPlanner.Domain.Push;
using FluentValidation;
namespace FamilyPlanner.Shared.Push;
/// <summary>Web-Push-Konfiguration für den Client: aktiv nur mit konfigurierten VAPID-Schlüsseln (ADR-0014).</summary>
public sealed record PushConfigDto(bool Enabled, string? PublicKey);
/// <summary>Abo aus <c>PushManager.subscribe()</c> (Schlüssel Base64url).</summary>
public sealed record PushSubscriptionRequest(string Endpoint, string P256dh, string Auth);
public sealed record PushUnsubscribeRequest(string Endpoint);
public sealed record PushTestResult(int Sent, int Failed);
public sealed class PushSubscriptionRequestValidator : AbstractValidator<PushSubscriptionRequest>
{
public PushSubscriptionRequestValidator()
{
RuleFor(r => r.Endpoint).Must(e => PushRules.IsAllowedEndpoint(e))
.WithMessage("Dieser Push-Dienst wird nicht unterstützt.");
RuleFor(r => r.P256dh).Must((r, _) => PushRules.AreValidKeys(r.P256dh, r.Auth))
.WithMessage("Die Schlüssel des Abos sind ungültig.");
}
}
public sealed class PushUnsubscribeRequestValidator : AbstractValidator<PushUnsubscribeRequest>
{
public PushUnsubscribeRequestValidator() =>
RuleFor(r => r.Endpoint).NotEmpty().MaximumLength(PushSubscription.EndpointMaxLength).WithMessage("Ungültiger Endpunkt.");
}
@@ -0,0 +1,260 @@
using FamilyPlanner.Domain.Recurrence;
using FamilyPlanner.Domain.Tasks;
using FluentValidation;
namespace FamilyPlanner.Shared.Tasks;
public sealed record TaskChecklistItemDto(Guid Id, string Text, bool IsDone);
/// <summary>Aufgabe aus Sicht des Clients. Fälligkeit als Tag/Uhrzeit in der Familienzeitzone und als UTC-Zeitpunkt.</summary>
public sealed record TaskDto(
Guid Id,
string Title,
string? Notes,
DateOnly? DueDate,
TimeOnly? DueTime,
DateTimeOffset? DueAt,
int? ReminderMinutesBefore,
TodoPriority Priority,
TodoStatus Status,
string? Category,
bool IsPrivate,
bool IsChore,
int Points,
bool NeedsApproval,
bool AssignedToFamily,
IReadOnlyList<Guid> AssigneeIds,
string? RecurrenceRule,
RecurrenceMode RecurrenceMode,
Guid? SeriesId,
IReadOnlyList<TaskChecklistItemDto> Checklist,
Guid? CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? CompletedAt,
Guid? CompletedBy,
byte[] Version) : ITaskFacts;
public sealed record TaskHistoryEntryDto(
DateTimeOffset At,
Guid? UserId,
string? UserName,
TaskHistoryAction Action,
TodoStatus? FromStatus,
TodoStatus? ToStatus,
string? Note);
public sealed record TaskDetailDto(TaskDto Task, IReadOnlyList<TaskHistoryEntryDto> History);
/// <summary>Gemeinsame, bearbeitbare Felder von Anlegen und Ändern.</summary>
public interface ITaskFields
{
string Title { get; }
string? Notes { get; }
DateOnly? DueDate { get; }
TimeOnly? DueTime { get; }
int? ReminderMinutesBefore { get; }
TodoPriority Priority { get; }
string? Category { get; }
bool IsPrivate { get; }
bool IsChore { get; }
int Points { get; }
bool NeedsApproval { get; }
bool AssignedToFamily { get; }
IReadOnlyList<Guid>? AssigneeIds { get; }
string? RecurrenceRule { get; }
RecurrenceMode RecurrenceMode { get; }
IReadOnlyList<TaskChecklistItemDto>? Checklist { get; }
}
/// <summary>Neue Aufgabe. <paramref name="Id"/> erzeugt der Client – wiederholtes Senden (Offline-Outbox) ist unschädlich.</summary>
public sealed record CreateTaskRequest(
Guid Id,
string Title,
string? Notes = null,
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
int? ReminderMinutesBefore = null,
TodoPriority Priority = TodoPriority.Normal,
string? Category = null,
bool IsPrivate = false,
bool IsChore = false,
int Points = 0,
bool NeedsApproval = false,
bool AssignedToFamily = false,
IReadOnlyList<Guid>? AssigneeIds = null,
string? RecurrenceRule = null,
RecurrenceMode RecurrenceMode = RecurrenceMode.FromDueDate,
IReadOnlyList<TaskChecklistItemDto>? Checklist = null) : ITaskFields;
/// <summary>Aufgabe ändern (alle Felder). Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
public sealed record UpdateTaskRequest(
string Title,
string? Notes = null,
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
int? ReminderMinutesBefore = null,
TodoPriority Priority = TodoPriority.Normal,
string? Category = null,
bool IsPrivate = false,
bool IsChore = false,
int Points = 0,
bool NeedsApproval = false,
bool AssignedToFamily = false,
IReadOnlyList<Guid>? AssigneeIds = null,
string? RecurrenceRule = null,
RecurrenceMode RecurrenceMode = RecurrenceMode.FromDueDate,
IReadOnlyList<TaskChecklistItemDto>? Checklist = null,
byte[]? Version = null) : ITaskFields
{
public static UpdateTaskRequest From(TaskDto task) => new(
task.Title, task.Notes, task.DueDate, task.DueTime, task.ReminderMinutesBefore, task.Priority, task.Category, task.IsPrivate,
task.IsChore, task.Points, task.NeedsApproval, task.AssignedToFamily, task.AssigneeIds, task.RecurrenceRule, task.RecurrenceMode,
task.Checklist, task.Version);
}
/// <summary>
/// Zielstatus setzen (offen, in Arbeit, erledigt). Zustandsbasiert und damit idempotent; „erledigt“ durch ein Kind kann
/// zu „wartet auf Bestätigung“ führen (ADR-0014).
/// </summary>
public sealed record SetTaskStatusRequest(TodoStatus Status);
/// <summary>Ergebnis einer Statusänderung bzw. Bestätigung; bei Wiederholungen mit der neu erzeugten nächsten Instanz.</summary>
public sealed record TaskStatusResult(TaskDto Task, TaskDto? NextInstance);
public sealed record RejectTaskRequest(string? Reason = null);
public sealed record SetChecklistItemRequest(bool IsDone);
public sealed record PointsBalanceDto(Guid UserId, string DisplayName, string Color, string? AvatarUrl, int Points);
public sealed record PointsEntryDto(Guid Id, Guid UserId, int Points, string Reason, Guid? TaskId, DateTimeOffset At);
public sealed record PointsOverviewDto(bool Enabled, IReadOnlyList<PointsBalanceDto> Balances, IReadOnlyList<PointsEntryDto> Recent);
/// <summary>Manuelle Punktekorrektur durch Eltern (z. B. Belohnung eingelöst), wird im Audit-Log protokolliert.</summary>
public sealed record AdjustPointsRequest(Guid UserId, int Points, string Reason);
/// <summary>Namen der SignalR-Ereignisse der Aufgaben am <c>/hubs/live</c>-Hub (nur an Benutzer, die die Aufgabe sehen dürfen).</summary>
public static class TaskEvents
{
public const string TaskChanged = "TaskChanged";
public const string TaskRemoved = "TaskRemoved";
/// <summary>Ohne Nutzdaten: Punktestände neu laden.</summary>
public const string PointsChanged = "TaskPointsChanged";
}
/// <summary>Auswahl für Erinnerungen in der Oberfläche.</summary>
public static class TaskReminderOptions
{
public static IReadOnlyList<(int? Minutes, string Label)> All { get; } =
[
(null, "Keine Erinnerung"),
(0, "Zur Fälligkeit"),
(15, "15 Minuten vorher"),
(60, "1 Stunde vorher"),
(24 * 60, "1 Tag vorher"),
];
}
internal static class TaskValidation
{
public static void Fields<T>(AbstractValidator<T> validator)
where T : ITaskFields
{
validator.RuleFor(r => r.Title)
.Must(t => !string.IsNullOrWhiteSpace(t)).WithMessage("Bitte einen Titel eingeben.")
.Must(t => t is null || t.Trim().Length <= TodoTask.TitleMaxLength)
.WithMessage($"Der Titel darf höchstens {TodoTask.TitleMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Notes).MaximumLength(TodoTask.NotesMaxLength)
.WithMessage($"Die Beschreibung darf höchstens {TodoTask.NotesMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Category).MaximumLength(TodoTask.CategoryMaxLength).WithMessage("Unbekannte Kategorie.");
validator.RuleFor(r => r.Priority).IsInEnum().WithMessage("Unbekannte Priorität.");
validator.RuleFor(r => r.RecurrenceMode).IsInEnum().WithMessage("Unbekannte Wiederholungsart.");
validator.RuleFor(r => r.DueTime).Null().When(r => r.DueDate is null)
.WithMessage("Eine Uhrzeit braucht ein Fälligkeitsdatum.");
validator.RuleFor(r => r.ReminderMinutesBefore)
.InclusiveBetween(0, TodoTask.MaxReminderMinutes).When(r => r.ReminderMinutesBefore is not null)
.WithMessage("Die Erinnerung muss zwischen 0 Minuten und 7 Tagen vor der Fälligkeit liegen.");
validator.RuleFor(r => r.ReminderMinutesBefore).Null().When(r => r.DueDate is null)
.WithMessage("Eine Erinnerung braucht ein Fälligkeitsdatum.");
validator.RuleFor(r => r.Points).InclusiveBetween(0, TodoTask.MaxPoints)
.WithMessage($"Punkte müssen zwischen 0 und {TodoTask.MaxPoints} liegen.");
validator.RuleFor(r => r.Points).Equal(0).When(r => !r.IsChore)
.WithMessage("Punkte gibt es nur für Ämtchen.");
validator.RuleFor(r => r.IsPrivate).Equal(false).When(r => r.AssignedToFamily)
.WithMessage("Eine Aufgabe für die ganze Familie kann nicht privat sein.");
validator.RuleFor(r => r.AssigneeIds)
.Must(ids => ids is null || ids.Count == 0).When(r => r.AssignedToFamily)
.WithMessage("Entweder die ganze Familie oder einzelne Personen zuweisen.")
.Must(ids => ids is null || (ids.Count <= TodoTask.MaxAssignees && ids.All(id => id != Guid.Empty)))
.WithMessage("Ungültige Zuweisung.");
validator.RuleFor(r => r.RecurrenceRule)
.Must(rule => rule is null || RecurrenceRule.TryParse(rule, out _, out _))
.WithMessage((request, rule) => RecurrenceRule.TryParse(rule, out _, out var error) ? string.Empty : error ?? "Ungültige Wiederholung.");
validator.RuleFor(r => r.DueDate).NotNull().When(r => !string.IsNullOrWhiteSpace(r.RecurrenceRule))
.WithMessage("Wiederkehrende Aufgaben brauchen ein Fälligkeitsdatum.");
validator.RuleFor(r => r.Checklist)
.Must(items => items is null || items.Count <= TodoTask.MaxChecklistItems)
.WithMessage($"Höchstens {TodoTask.MaxChecklistItems} Checklistenpunkte.")
.Must(items => items is null || items.All(i => !string.IsNullOrWhiteSpace(i.Text) && i.Text.Trim().Length <= TodoTask.ChecklistTextMaxLength))
.WithMessage($"Checklistenpunkte brauchen einen Text mit höchstens {TodoTask.ChecklistTextMaxLength} Zeichen.");
}
}
public sealed class CreateTaskRequestValidator : AbstractValidator<CreateTaskRequest>
{
public CreateTaskRequestValidator()
{
RuleFor(r => r.Id).NotEmpty().WithMessage("Die Aufgabe braucht eine Id.");
TaskValidation.Fields(this);
}
}
public sealed class UpdateTaskRequestValidator : AbstractValidator<UpdateTaskRequest>
{
public UpdateTaskRequestValidator() => TaskValidation.Fields(this);
}
public sealed class SetTaskStatusRequestValidator : AbstractValidator<SetTaskStatusRequest>
{
public SetTaskStatusRequestValidator() =>
RuleFor(r => r.Status).Must(s => Enum.IsDefined(s) && TaskWorkflow.IsRequestable(s))
.WithMessage("Erlaubt sind „offen“, „in Arbeit“ und „erledigt“.");
}
public sealed class RejectTaskRequestValidator : AbstractValidator<RejectTaskRequest>
{
public RejectTaskRequestValidator() =>
RuleFor(r => r.Reason).MaximumLength(TaskHistoryEntry.NoteMaxLength)
.WithMessage($"Die Begründung darf höchstens {TaskHistoryEntry.NoteMaxLength} Zeichen haben.");
}
public sealed class AdjustPointsRequestValidator : AbstractValidator<AdjustPointsRequest>
{
public AdjustPointsRequestValidator()
{
RuleFor(r => r.UserId).NotEmpty().WithMessage("Bitte eine Person wählen.");
RuleFor(r => r.Points).NotEqual(0).WithMessage("Bitte eine Punktzahl ungleich 0 eingeben.")
.InclusiveBetween(-1000, 1000).WithMessage("Die Korrektur muss zwischen -1000 und 1000 Punkten liegen.");
RuleFor(r => r.Reason).Must(r => !string.IsNullOrWhiteSpace(r)).WithMessage("Bitte einen Grund angeben.")
.MaximumLength(PointsLedgerEntry.ReasonMaxLength)
.WithMessage($"Der Grund darf höchstens {PointsLedgerEntry.ReasonMaxLength} Zeichen haben.");
}
}
@@ -23,6 +23,7 @@ public sealed class PermissionMatrixTests
{ Permission.TasksEdit, F, F, R, N }, // Kind: nur eigene, private Aufgaben
{ Permission.TasksAssign, F, F, N, N },
{ Permission.TasksCompleteAssigned, F, F, F, F },
{ Permission.TasksApprove, F, F, N, N }, // Eltern-Bestätigung und Punktekorrektur (ADR-0014)
{ Permission.CalendarRead, F, F, F, R }, // Gast: nur freigegebene Kalender
{ Permission.EventsEdit, F, F, R, N }, // Kind: nur eigene Termine
{ Permission.FamilyChat, F, F, F, N }, // Gast: nur wenn freigegeben