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:
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Generated
+1240
File diff suppressed because it is too large.
Load diff
+254
@@ -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
|
||||
|
||||
Reference in new issue
Block a user