diff --git a/deploy/.env.example b/deploy/.env.example
index da76ba3..84ddc4c 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -13,6 +13,13 @@ ADMIN_PASSWORD=Erstes-Passwort-2026
ADMIN_EMAIL=
FAMILY_NAME=Familie Muster
+# --- Web-Push-Erinnerungen (optional) -------------------------------------------
+# Schlüsselpaar erzeugen: dotnet run tools/generate-vapid-keys.cs (aus dem Repository). Leer = keine Push-Erinnerungen.
+# SUBJECT: Kontakt für die Push-Dienste (mailto: oder https:).
+VAPID_PUBLIC_KEY=
+VAPID_PRIVATE_KEY=
+VAPID_SUBJECT=mailto:admin@example.org
+
# --- Netzwerk -------------------------------------------------------------------
# Ohne Caddy: Port des Web-Containers auf dem Host (127.0.0.1 = nur lokal bzw. für eigenen Reverse Proxy).
WEB_BIND=127.0.0.1
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index aa52833..bc33d7f 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -64,6 +64,10 @@ services:
<<: *connection-string
ASPNETCORE_ENVIRONMENT: Production
TZ: Europe/Berlin
+ # Web-Push-Erinnerungen (optional, ADR-0014) – leer = deaktiviert
+ WebPush__PublicKey: ${VAPID_PUBLIC_KEY:-}
+ WebPush__PrivateKey: ${VAPID_PRIVATE_KEY:-}
+ WebPush__Subject: ${VAPID_SUBJECT:-mailto:admin@localhost}
depends_on:
migrations:
condition: service_completed_successfully
diff --git a/src/FamilyPlanner.Api/Features/Push/PushEndpoints.cs b/src/FamilyPlanner.Api/Features/Push/PushEndpoints.cs
new file mode 100644
index 0000000..b99ab38
--- /dev/null
+++ b/src/FamilyPlanner.Api/Features/Push/PushEndpoints.cs
@@ -0,0 +1,122 @@
+using System.Security.Claims;
+using FamilyPlanner.Api.Infrastructure;
+using FamilyPlanner.Domain.Push;
+using FamilyPlanner.Infrastructure.Persistence;
+using FamilyPlanner.Shared.Push;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.EntityFrameworkCore;
+
+namespace FamilyPlanner.Api.Features.Push;
+
+/// Web-Push vorbereiten: Konfiguration, Abos speichern/löschen, Testnachricht (ADR-0014). Alle angemeldeten Benutzer.
+public static class PushEndpoints
+{
+ public static IEndpointRouteBuilder MapPushEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/push").WithTags("Benachrichtigungen");
+
+ group.MapGet("/config", GetConfig);
+ group.MapPost("/subscriptions", SubscribeAsync).Validate();
+ group.MapPost("/unsubscribe", UnsubscribeAsync).Validate();
+ group.MapPost("/test", SendTestAsync);
+
+ return app;
+ }
+
+ internal static Ok GetConfig(IWebPushSender sender) =>
+ TypedResults.Ok(new PushConfigDto(sender.IsEnabled, sender.PublicKey));
+
+ /// Speichert das Abo des Geräts (Upsert über den Endpunkt; ein Gerät gehört immer dem zuletzt angemeldeten Benutzer).
+ internal static async Task SubscribeAsync(
+ PushSubscriptionRequest request, ClaimsPrincipal user, HttpContext context, AppDbContext db, CancellationToken ct)
+ {
+ var userId = user.UserId() ?? Guid.Empty;
+ var hash = PushSubscription.Hash(request.Endpoint);
+ var subscription = await db.PushSubscriptions.SingleOrDefaultAsync(s => s.EndpointHash == hash, ct);
+ if (subscription is null)
+ {
+ subscription = new PushSubscription { Endpoint = request.Endpoint, EndpointHash = hash };
+ db.PushSubscriptions.Add(subscription);
+ }
+
+ subscription.UserId = userId;
+ subscription.P256dh = request.P256dh;
+ subscription.Auth = request.Auth;
+ subscription.FailureCount = 0;
+ var userAgent = context.Request.Headers.UserAgent.ToString();
+ subscription.UserAgent = userAgent.Length > PushSubscription.UserAgentMaxLength
+ ? userAgent[..PushSubscription.UserAgentMaxLength]
+ : userAgent.Length == 0 ? null : userAgent;
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException)
+ {
+ // Gleichzeitig registriert: das vorhandene Abo genügt.
+ db.ChangeTracker.Clear();
+ }
+
+ return TypedResults.NoContent();
+ }
+
+ internal static async Task UnsubscribeAsync(
+ PushUnsubscribeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct)
+ {
+ var userId = user.UserId() ?? Guid.Empty;
+ var hash = PushSubscription.Hash(request.Endpoint);
+ await db.PushSubscriptions.Where(s => s.EndpointHash == hash && s.UserId == userId).ExecuteDeleteAsync(ct);
+ return TypedResults.NoContent(); // idempotent
+ }
+
+ internal static async Task, ProblemHttpResult>> SendTestAsync(
+ ClaimsPrincipal user, AppDbContext db, IWebPushSender sender, TimeProvider timeProvider, CancellationToken ct)
+ {
+ if (!sender.IsEnabled)
+ {
+ return EndpointHelpers.Conflict("Benachrichtigungen sind auf diesem Server nicht eingerichtet (VAPID-Schlüssel fehlen).");
+ }
+
+ var userId = user.UserId() ?? Guid.Empty;
+ var subscriptions = await db.PushSubscriptions.Where(s => s.UserId == userId).ToListAsync(ct);
+ var message = new PushMessage("Familienplaner", "Benachrichtigungen funktionieren. 🎉", "/", "test");
+ var (sent, failed) = await SendAsync(db, sender, subscriptions, message, timeProvider, ct);
+ return TypedResults.Ok(new PushTestResult(sent, failed));
+ }
+
+ /// Sendet an alle Abos; abgelaufene (404/410) bzw. dauerhaft fehlerhafte Abos werden entfernt.
+ internal static async Task<(int Sent, int Failed)> SendAsync(
+ AppDbContext db, IWebPushSender sender, IEnumerable subscriptions, PushMessage message,
+ TimeProvider timeProvider, CancellationToken ct)
+ {
+ const int MaxFailures = 10;
+ var (sent, failed) = (0, 0);
+ foreach (var subscription in subscriptions)
+ {
+ switch (await sender.SendAsync(subscription, message, ct))
+ {
+ case PushSendResult.Sent:
+ sent++;
+ subscription.LastSuccessAt = timeProvider.GetUtcNow();
+ subscription.FailureCount = 0;
+ break;
+ case PushSendResult.Gone:
+ failed++;
+ db.PushSubscriptions.Remove(subscription);
+ break;
+ default:
+ failed++;
+ if (++subscription.FailureCount >= MaxFailures)
+ {
+ db.PushSubscriptions.Remove(subscription);
+ }
+
+ break;
+ }
+ }
+
+ await db.SaveChangesAsync(ct);
+ return (sent, failed);
+ }
+}
diff --git a/src/FamilyPlanner.Api/Features/Push/WebPushSender.cs b/src/FamilyPlanner.Api/Features/Push/WebPushSender.cs
new file mode 100644
index 0000000..0c80765
--- /dev/null
+++ b/src/FamilyPlanner.Api/Features/Push/WebPushSender.cs
@@ -0,0 +1,229 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using FamilyPlanner.Domain.Push;
+using Microsoft.Extensions.Options;
+
+namespace FamilyPlanner.Api.Features.Push;
+
+///
+/// VAPID-Konfiguration (Abschnitt WebPush). Schlüssel kommen aus Aspire-Parametern bzw. .env – nie aus dem Repository.
+/// Erzeugen: dotnet run tools/generate-vapid-keys.cs (siehe README).
+///
+public sealed class WebPushOptions
+{
+ public const string SectionName = "WebPush";
+
+ /// Öffentlicher Schlüssel (P-256, unkomprimiert, Base64url).
+ public string? PublicKey { get; set; }
+
+ /// Privater Schlüssel (32 Byte, Base64url).
+ public string? PrivateKey { get; set; }
+
+ /// Kontakt für die Push-Dienste (mailto: oder https:).
+ public string Subject { get; set; } = "mailto:admin@localhost";
+
+ public bool IsConfigured => !string.IsNullOrWhiteSpace(PublicKey) && !string.IsNullOrWhiteSpace(PrivateKey);
+}
+
+/// Inhalt einer Benachrichtigung; der Service Worker zeigt sie an und öffnet beim Antippen .
+public sealed record PushMessage(string Title, string Body, string? Url = null, string? Tag = null);
+
+public enum PushSendResult
+{
+ Sent,
+
+ /// Abo existiert beim Push-Dienst nicht mehr (404/410) – löschen.
+ Gone,
+
+ Failed,
+}
+
+public interface IWebPushSender
+{
+ bool IsEnabled { get; }
+
+ string? PublicKey { get; }
+
+ Task SendAsync(PushSubscription subscription, PushMessage message, CancellationToken ct);
+}
+
+/// Web Push (RFC 8030) mit VAPID (RFC 8292) und verschlüsselten Nutzdaten (RFC 8291, aes128gcm) – ohne Fremdbibliothek.
+internal sealed class WebPushSender(HttpClient http, IOptions options, TimeProvider timeProvider, ILogger logger)
+ : IWebPushSender
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly WebPushOptions _options = options.Value;
+
+ public bool IsEnabled => _options.IsConfigured;
+
+ public string? PublicKey => _options.IsConfigured ? _options.PublicKey : null;
+
+ public async Task SendAsync(PushSubscription subscription, PushMessage message, CancellationToken ct)
+ {
+ if (!IsEnabled || !PushRules.IsAllowedEndpoint(subscription.Endpoint))
+ {
+ return PushSendResult.Failed;
+ }
+
+ var userKey = PushRules.FromBase64Url(subscription.P256dh);
+ var authSecret = PushRules.FromBase64Url(subscription.Auth);
+ if (userKey is not { Length: 65 } || authSecret is not { Length: 16 })
+ {
+ return PushSendResult.Gone;
+ }
+
+ var payload = JsonSerializer.SerializeToUtf8Bytes(message, JsonOptions);
+ var body = WebPushCrypto.Encrypt(payload, userKey, authSecret);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint) { Content = new ByteArrayContent(body) };
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
+ request.Content.Headers.ContentEncoding.Add("aes128gcm");
+ request.Headers.Add("TTL", "14400");
+ request.Headers.Add("Urgency", "normal");
+ request.Headers.TryAddWithoutValidation("Authorization",
+ $"vapid t={WebPushCrypto.CreateVapidJwt(new Uri(subscription.Endpoint), _options, timeProvider.GetUtcNow())}, k={_options.PublicKey}");
+
+ try
+ {
+ using var response = await http.SendAsync(request, ct);
+ if (response.IsSuccessStatusCode)
+ {
+ return PushSendResult.Sent;
+ }
+
+ if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
+ {
+ return PushSendResult.Gone;
+ }
+
+ logger.LogWarning("Push-Dienst antwortete mit {Status}.", (int)response.StatusCode);
+ return PushSendResult.Failed;
+ }
+ catch (HttpRequestException ex)
+ {
+ logger.LogWarning(ex, "Push-Nachricht konnte nicht gesendet werden.");
+ return PushSendResult.Failed;
+ }
+ }
+}
+
+/// Kryptografie für Web Push: Inhaltsverschlüsselung nach RFC 8291 und VAPID-JWT (ES256).
+internal static class WebPushCrypto
+{
+ private const int RecordSize = 4096;
+
+ ///
+ /// Verschlüsselt für ein Browser-Abo (Content-Encoding aes128gcm, ein Record).
+ /// und nur für Tests vorgeben.
+ ///
+ public static byte[] Encrypt(ReadOnlySpan plaintext, byte[] userAgentPublicKey, byte[] authSecret,
+ ECDiffieHellman? senderKey = null, byte[]? salt = null)
+ {
+ using var ownKey = senderKey is null ? ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256) : null;
+ var key = senderKey ?? ownKey!;
+ salt ??= RandomNumberGenerator.GetBytes(16);
+ var senderPublic = ExportPublicKey(key);
+
+ var (contentKey, nonce) = DeriveKeys(key, userAgentPublicKey, senderPublic, userAgentPublicKey, authSecret, salt);
+
+ var padded = new byte[plaintext.Length + 1];
+ plaintext.CopyTo(padded);
+ padded[^1] = 0x02; // Trennzeichen des letzten Records
+
+ var cipher = new byte[padded.Length];
+ var tag = new byte[16];
+ using (var aes = new AesGcm(contentKey, tag.Length))
+ {
+ aes.Encrypt(nonce, padded, cipher, tag);
+ }
+
+ var body = new byte[16 + 4 + 1 + senderPublic.Length + cipher.Length + tag.Length];
+ var offset = 0;
+ salt.CopyTo(body, offset);
+ offset += 16;
+ BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(offset, 4), RecordSize);
+ offset += 4;
+ body[offset++] = (byte)senderPublic.Length;
+ senderPublic.CopyTo(body, offset);
+ offset += senderPublic.Length;
+ cipher.CopyTo(body, offset);
+ offset += cipher.Length;
+ tag.CopyTo(body, offset);
+ return body;
+ }
+
+ /// Gegenstück zu aus Sicht des Browsers (für Tests).
+ public static byte[] Decrypt(byte[] body, ECDiffieHellman userAgentKey, byte[] authSecret)
+ {
+ var salt = body[..16];
+ var idLength = body[20];
+ var senderPublic = body[21..(21 + idLength)];
+ var cipherAndTag = body[(21 + idLength)..];
+ var userAgentPublic = ExportPublicKey(userAgentKey);
+
+ var (contentKey, nonce) = DeriveKeys(userAgentKey, senderPublic, senderPublic, userAgentPublic, authSecret, salt);
+
+ var cipher = cipherAndTag[..^16];
+ var tag = cipherAndTag[^16..];
+ var padded = new byte[cipher.Length];
+ using (var aes = new AesGcm(contentKey, tag.Length))
+ {
+ aes.Decrypt(nonce, cipher, tag, padded);
+ }
+
+ var end = Array.LastIndexOf(padded, (byte)0x02);
+ return padded[..end];
+ }
+
+ /// VAPID-Token: aud = Origin des Push-Dienstes, gültig 12 Stunden, signiert mit ES256 (IEEE P1363).
+ public static string CreateVapidJwt(Uri endpoint, WebPushOptions options, DateTimeOffset now)
+ {
+ var header = PushRules.ToBase64Url("{\"typ\":\"JWT\",\"alg\":\"ES256\"}"u8);
+ var claims = PushRules.ToBase64Url(JsonSerializer.SerializeToUtf8Bytes(new Dictionary
+ {
+ ["aud"] = endpoint.GetLeftPart(UriPartial.Authority),
+ ["exp"] = now.AddHours(12).ToUnixTimeSeconds(),
+ ["sub"] = options.Subject,
+ }));
+
+ var publicKey = PushRules.FromBase64Url(options.PublicKey) ?? throw new InvalidOperationException("VAPID-Schlüssel ungültig.");
+ var privateKey = PushRules.FromBase64Url(options.PrivateKey) ?? throw new InvalidOperationException("VAPID-Schlüssel ungültig.");
+ using var ecdsa = ECDsa.Create(new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ D = privateKey,
+ Q = new ECPoint { X = publicKey[1..33], Y = publicKey[33..65] },
+ });
+ var signature = ecdsa.SignData(Encoding.ASCII.GetBytes($"{header}.{claims}"), HashAlgorithmName.SHA256);
+ return $"{header}.{claims}.{PushRules.ToBase64Url(signature)}";
+ }
+
+ public static byte[] ExportPublicKey(ECDiffieHellman key)
+ {
+ var parameters = key.ExportParameters(false);
+ return [0x04, .. parameters.Q.X!, .. parameters.Q.Y!];
+ }
+
+ private static (byte[] ContentKey, byte[] Nonce) DeriveKeys(
+ ECDiffieHellman ownKey, byte[] otherPublic, byte[] senderPublic, byte[] userAgentPublic, byte[] authSecret, byte[] salt)
+ {
+ using var other = ECDiffieHellman.Create(new ECParameters
+ {
+ Curve = ECCurve.NamedCurves.nistP256,
+ Q = new ECPoint { X = otherPublic[1..33], Y = otherPublic[33..65] },
+ });
+ var sharedSecret = ownKey.DeriveRawSecretAgreement(other.PublicKey);
+
+ byte[] keyInfo = [.. "WebPush: info\0"u8, .. userAgentPublic, .. senderPublic];
+ var ikm = HKDF.DeriveKey(HashAlgorithmName.SHA256, sharedSecret, 32, authSecret, keyInfo);
+ var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt);
+ var contentKey = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, "Content-Encoding: aes128gcm\0"u8.ToArray());
+ var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, "Content-Encoding: nonce\0"u8.ToArray());
+ return (contentKey, nonce);
+ }
+}
diff --git a/src/FamilyPlanner.Api/Features/Tasks/TaskEndpoints.cs b/src/FamilyPlanner.Api/Features/Tasks/TaskEndpoints.cs
new file mode 100644
index 0000000..3e34c32
--- /dev/null
+++ b/src/FamilyPlanner.Api/Features/Tasks/TaskEndpoints.cs
@@ -0,0 +1,708 @@
+using System.Security.Claims;
+using FamilyPlanner.Api.Infrastructure;
+using FamilyPlanner.Domain.Audit;
+using FamilyPlanner.Domain.Authorization;
+using FamilyPlanner.Domain.Recurrence;
+using FamilyPlanner.Domain.Settings;
+using FamilyPlanner.Domain.Tasks;
+using FamilyPlanner.Infrastructure.Audit;
+using FamilyPlanner.Infrastructure.Identity;
+using FamilyPlanner.Infrastructure.Persistence;
+using FamilyPlanner.Infrastructure.Settings;
+using FamilyPlanner.Shared.Tasks;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.EntityFrameworkCore;
+
+namespace FamilyPlanner.Api.Features.Tasks;
+
+/// Aufgaben und Ämtchen: CRUD, Status mit Verlauf, Eltern-Bestätigung, Punkte, Wiederholungen (ADR-0014).
+public static class TaskEndpoints
+{
+ /// Erledigte Aufgaben werden so viele Tage lang mitgeliefert (danach nur noch im Verlauf/der Datenbank).
+ internal const int DefaultDoneDays = 14;
+
+ private const int MaxStatusAttempts = 3;
+ private const int RecentPointsEntries = 30;
+
+ public static IEndpointRouteBuilder MapTaskEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/tasks").WithTags("Aufgaben");
+
+ group.MapGet("/", ListAsync).RequirePermission(Permission.TasksRead);
+ group.MapGet("/{id:guid}", GetAsync).RequirePermission(Permission.TasksRead);
+ group.MapPost("/", CreateAsync).RequirePermission(Permission.TasksEdit).Validate();
+ group.MapPut("/{id:guid}", UpdateAsync).RequirePermission(Permission.TasksEdit).Validate();
+ group.MapDelete("/{id:guid}", DeleteAsync).RequirePermission(Permission.TasksEdit);
+
+ group.MapPost("/{id:guid}/status", SetStatusAsync).RequirePermission(Permission.TasksCompleteAssigned).Validate();
+ group.MapPut("/{id:guid}/checklist/{itemId:guid}", SetChecklistItemAsync).RequirePermission(Permission.TasksCompleteAssigned);
+ group.MapPost("/{id:guid}/approve", ApproveAsync).RequirePermission(Permission.TasksApprove);
+ group.MapPost("/{id:guid}/reject", RejectAsync).RequirePermission(Permission.TasksApprove).Validate();
+
+ group.MapGet("/points", GetPointsAsync).RequireAuthorization(Policies.FamilyMember);
+ group.MapPost("/points/adjust", AdjustPointsAsync).RequirePermission(Permission.TasksApprove).Validate();
+
+ return app;
+ }
+
+ // ---------- Lesen ----------
+
+ internal static async Task>> ListAsync(
+ int? doneDays, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, TimeProvider timeProvider, CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var since = timeProvider.GetUtcNow().AddDays(-Math.Clamp(doneDays ?? DefaultDoneDays, 0, 365));
+ var userId = actor.UserId;
+
+ var tasks = await Query(db, tracking: false)
+ .Where(t => t.Status != TodoStatus.Done || t.CompletedAt >= since)
+ // Private Aufgaben anderer verlassen nie die Datenbank (zusätzlich zur Prüfung unten).
+ .Where(t => !t.IsPrivate || t.CreatedBy == userId || t.Assignments.Any(a => a.UserId == userId))
+ .ToListAsync(ct);
+
+ return TypedResults.Ok(tasks.Where(t => TaskRules.CanSee(t, actor)).Select(ToDto).ToList());
+ }
+
+ internal static async Task, NotFound>> GetAsync(
+ Guid id, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var task = await Query(db, tracking: false).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null || !TaskRules.CanSee(task, actor))
+ {
+ return TypedResults.NotFound();
+ }
+
+ var history = await db.TaskHistory.AsNoTracking().Where(h => h.TaskId == id).OrderBy(h => h.CreatedAt).ToListAsync(ct);
+ var names = await UserNamesAsync(db, history.Select(h => h.CreatedBy), ct);
+ return TypedResults.Ok(new TaskDetailDto(
+ ToDto(task),
+ history.Select(h => new TaskHistoryEntryDto(
+ h.CreatedAt, h.CreatedBy, h.CreatedBy is { } by ? names.GetValueOrDefault(by) : null, h.Action, h.FromStatus, h.ToStatus, h.Note)).ToList()));
+ }
+
+ // ---------- Anlegen, Ändern, Löschen ----------
+
+ internal static async Task, Ok, NoContent, ProblemHttpResult, ValidationProblem>> CreateAsync(
+ CreateTaskRequest request,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ ISettingsProvider settingsProvider,
+ TaskNotifier notifier,
+ CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+
+ // Idempotenz: dieselbe Client-Id erneut gesendet (Offline-Outbox).
+ var existing = await Query(db, tracking: false).IgnoreQueryFilters().SingleOrDefaultAsync(t => t.Id == request.Id, ct);
+ if (existing is not null)
+ {
+ if (existing.IsDeleted)
+ {
+ return TypedResults.NoContent(); // gelöscht bleibt gelöscht
+ }
+
+ return TaskRules.CanSee(existing, actor)
+ ? TypedResults.Ok(ToDto(existing))
+ : EndpointHelpers.Conflict("Diese Aufgaben-Id ist bereits vergeben.");
+ }
+
+ var settings = await settingsProvider.GetAsync(ct);
+ var task = new TodoTask { Id = request.Id };
+ var check = await CheckFieldsAsync(request, null, actor, settings, db, ct);
+ if (check.Problem is { } problem)
+ {
+ return problem;
+ }
+
+ if (check.Invalid is { } invalid)
+ {
+ return invalid;
+ }
+
+ Apply(task, request, actor, settings.GetTimeZone());
+ db.Tasks.Add(task);
+ db.TaskHistory.Add(new TaskHistoryEntry { TaskId = task.Id, Action = TaskHistoryAction.Created, ToStatus = task.Status });
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException)
+ {
+ // Gleichzeitig mit derselben Id angelegt (z. B. doppelt gesendete Outbox).
+ db.ChangeTracker.Clear();
+ var current = await Query(db, tracking: false).SingleOrDefaultAsync(t => t.Id == request.Id, ct);
+ if (current is null || !TaskRules.CanSee(current, actor))
+ {
+ throw;
+ }
+
+ return TypedResults.Ok(ToDto(current));
+ }
+
+ var dto = ToDto(task);
+ await notifier.ChangedAsync(dto, null, ct);
+ return TypedResults.Created($"/api/tasks/{task.Id}", dto);
+ }
+
+ internal static async Task, NotFound, Conflict, ForbidHttpResult, ProblemHttpResult, ValidationProblem>> UpdateAsync(
+ Guid id,
+ UpdateTaskRequest request,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ ISettingsProvider settingsProvider,
+ TaskNotifier notifier,
+ CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var task = await Query(db, tracking: true).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null || !TaskRules.CanSee(task, actor))
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!TaskRules.CanEdit(task, actor))
+ {
+ return TypedResults.Forbid();
+ }
+
+ if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(task.RowVersion))
+ {
+ return TypedResults.Conflict(ToDto(task)); // jemand anderes hat inzwischen geändert
+ }
+
+ var settings = await settingsProvider.GetAsync(ct);
+ var check = await CheckFieldsAsync(request, task, actor, settings, db, ct);
+ if (check.Problem is { } problem)
+ {
+ return problem;
+ }
+
+ if (check.Invalid is { } invalid)
+ {
+ return invalid;
+ }
+
+ var before = ToDto(task);
+ Apply(task, request, actor, settings.GetTimeZone());
+ db.TaskHistory.Add(new TaskHistoryEntry { TaskId = task.Id, Action = TaskHistoryAction.Updated });
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ db.ChangeTracker.Clear();
+ var current = await Query(db, tracking: false).SingleOrDefaultAsync(t => t.Id == id, ct);
+ return current is null ? TypedResults.NotFound() : TypedResults.Conflict(ToDto(current));
+ }
+
+ var dto = ToDto(task);
+ await notifier.ChangedAsync(dto, before, ct);
+ return TypedResults.Ok(dto);
+ }
+
+ internal static async Task> DeleteAsync(
+ Guid id, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, TaskNotifier notifier, CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var task = await Query(db, tracking: true).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null)
+ {
+ return TypedResults.NoContent(); // idempotent: bereits gelöscht oder nie angelegt
+ }
+
+ if (!TaskRules.CanSee(task, actor))
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!TaskRules.CanEdit(task, actor))
+ {
+ return TypedResults.Forbid();
+ }
+
+ var before = ToDto(task);
+ db.Tasks.Remove(task); // Soft Delete über den Interceptor
+ db.TaskHistory.Add(new TaskHistoryEntry { TaskId = task.Id, Action = TaskHistoryAction.Deleted, FromStatus = task.Status });
+ await db.SaveChangesAsync(ct);
+ await notifier.RemovedAsync(before, ct);
+ return TypedResults.NoContent();
+ }
+
+ // ---------- Status, Checkliste, Bestätigung ----------
+
+ internal static async Task, NotFound, ForbidHttpResult, ProblemHttpResult>> SetStatusAsync(
+ Guid id,
+ SetTaskStatusRequest request,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ ISettingsProvider settingsProvider,
+ TaskNotifier notifier,
+ TimeProvider timeProvider,
+ CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var settings = await settingsProvider.GetAsync(ct);
+
+ // Statusänderungen sind zustandsbasiert: bei gleichzeitiger Änderung neu laden und erneut bewerten.
+ for (var attempt = 1; ; attempt++)
+ {
+ var task = await Query(db, tracking: true).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null || !TaskRules.CanSee(task, actor))
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!TaskRules.CanChangeStatus(task, actor))
+ {
+ return TypedResults.Forbid();
+ }
+
+ var target = TaskWorkflow.Resolve(task, request.Status, actor, settings.ChildTasksRequireApproval);
+ if (target == task.Status)
+ {
+ return TypedResults.Ok(new TaskStatusResult(ToDto(task), null));
+ }
+
+ var before = ToDto(task);
+ var transition = TaskWorkflow.Apply(task, target, actor.UserId, timeProvider.GetUtcNow());
+ var history = new TaskHistoryEntry { TaskId = task.Id, Action = TaskHistoryAction.StatusChanged, FromStatus = transition.From, ToStatus = transition.To };
+ var outcome = await CompleteTransitionAsync(db, task, transition, history, settings, timeProvider, ct);
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException) when (attempt < MaxStatusAttempts)
+ {
+ db.ChangeTracker.Clear();
+ continue;
+ }
+
+ return TypedResults.Ok(await PublishAsync(notifier, task, before, outcome, ct));
+ }
+ }
+
+ internal static async Task, NotFound, ForbidHttpResult>> SetChecklistItemAsync(
+ Guid id,
+ Guid itemId,
+ SetChecklistItemRequest request,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ ISettingsProvider settingsProvider,
+ TaskNotifier notifier,
+ CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var task = await Query(db, tracking: true).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null || !TaskRules.CanSee(task, actor) || task.Checklist.SingleOrDefault(c => c.Id == itemId) is not { } item)
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!TaskRules.CanChangeStatus(task, actor))
+ {
+ return TypedResults.Forbid();
+ }
+
+ if (item.IsDone != request.IsDone)
+ {
+ item.IsDone = request.IsDone;
+ await db.SaveChangesAsync(ct);
+ var dto = ToDto(task);
+ await notifier.ChangedAsync(dto, dto, ct);
+ return TypedResults.Ok(dto);
+ }
+
+ return TypedResults.Ok(ToDto(task));
+ }
+
+ internal static async Task, NotFound, ProblemHttpResult>> ApproveAsync(
+ Guid id,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ ISettingsProvider settingsProvider,
+ TaskNotifier notifier,
+ TimeProvider timeProvider,
+ CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var task = await Query(db, tracking: true).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null || !TaskRules.CanSee(task, actor))
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!TaskRules.CanApprove(task, actor))
+ {
+ return EndpointHelpers.Conflict("Diese Aufgabe wartet nicht auf eine Bestätigung.");
+ }
+
+ var settings = await settingsProvider.GetAsync(ct);
+ var before = ToDto(task);
+ var transition = TaskWorkflow.Approve(task, actor.UserId, timeProvider.GetUtcNow());
+ var history = new TaskHistoryEntry { TaskId = task.Id, Action = TaskHistoryAction.Approved, FromStatus = transition.From, ToStatus = transition.To };
+ var outcome = await CompleteTransitionAsync(db, task, transition, history, settings, timeProvider, ct);
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ return EndpointHelpers.Conflict("Die Aufgabe wurde inzwischen geändert. Bitte neu laden.");
+ }
+
+ return TypedResults.Ok(await PublishAsync(notifier, task, before, outcome, ct));
+ }
+
+ internal static async Task, NotFound, ProblemHttpResult>> RejectAsync(
+ Guid id,
+ RejectTaskRequest request,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ ISettingsProvider settingsProvider,
+ TaskNotifier notifier,
+ TimeProvider timeProvider,
+ CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var task = await Query(db, tracking: true).SingleOrDefaultAsync(t => t.Id == id, ct);
+ if (task is null || !TaskRules.CanSee(task, actor))
+ {
+ return TypedResults.NotFound();
+ }
+
+ if (!TaskRules.CanApprove(task, actor))
+ {
+ return EndpointHelpers.Conflict("Diese Aufgabe wartet nicht auf eine Bestätigung.");
+ }
+
+ var before = ToDto(task);
+ var transition = TaskWorkflow.Reject(task, actor.UserId, timeProvider.GetUtcNow());
+ db.TaskHistory.Add(new TaskHistoryEntry
+ {
+ TaskId = task.Id,
+ Action = TaskHistoryAction.Rejected,
+ FromStatus = transition.From,
+ ToStatus = transition.To,
+ Note = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim(),
+ });
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ return EndpointHelpers.Conflict("Die Aufgabe wurde inzwischen geändert. Bitte neu laden.");
+ }
+
+ return TypedResults.Ok(await PublishAsync(notifier, task, before, new TransitionOutcome(null, false), ct));
+ }
+
+ // ---------- Punkte ----------
+
+ internal static async Task> GetPointsAsync(
+ ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, TimeProvider timeProvider, CancellationToken ct)
+ {
+ var actor = await user.TaskActorAsync(settingsProvider);
+ var settings = await settingsProvider.GetAsync(ct);
+ var sums = await db.PointsLedger.AsNoTracking()
+ .GroupBy(p => p.UserId)
+ .Select(g => new { UserId = g.Key, Points = g.Sum(p => p.Points) })
+ .ToDictionaryAsync(x => x.UserId, x => x.Points, ct);
+
+ var roles = await db.GetRoleMapAsync(ct);
+ var now = timeProvider.GetUtcNow();
+ var users = await db.Users.AsNoTracking()
+ .Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now))
+ .Select(u => new { u.Id, u.DisplayName, u.UserName, u.Color, u.AvatarUrl })
+ .ToListAsync(ct);
+
+ var balances = users
+ .Where(u => roles.GetValueOrDefault(u.Id) != Domain.Roles.Guest || sums.ContainsKey(u.Id))
+ .Select(u => new PointsBalanceDto(u.Id, string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName,
+ u.Color, u.AvatarUrl, sums.GetValueOrDefault(u.Id)))
+ .OrderByDescending(b => b.Points)
+ .ThenBy(b => b.DisplayName, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+
+ var recentQuery = db.PointsLedger.AsNoTracking();
+ if (!actor.CanApprove)
+ {
+ recentQuery = recentQuery.Where(p => p.UserId == actor.UserId); // Kinder sehen nur eigene Buchungen
+ }
+
+ var recent = await recentQuery
+ .OrderByDescending(p => p.CreatedAt)
+ .Take(RecentPointsEntries)
+ .Select(p => new PointsEntryDto(p.Id, p.UserId, p.Points, p.Reason, p.TaskId, p.CreatedAt))
+ .ToListAsync(ct);
+
+ return TypedResults.Ok(new PointsOverviewDto(settings.ChorePointsEnabled, balances, recent));
+ }
+
+ internal static async Task, ValidationProblem>> AdjustPointsAsync(
+ AdjustPointsRequest request,
+ ClaimsPrincipal user,
+ AppDbContext db,
+ IAuditLogger audit,
+ TaskNotifier notifier,
+ CancellationToken ct)
+ {
+ var target = await db.Users.AsNoTracking()
+ .Where(u => u.Id == request.UserId && u.IsActive)
+ .Select(u => new { u.Id, u.DisplayName, u.UserName })
+ .SingleOrDefaultAsync(ct);
+ if (target is null)
+ {
+ return TypedResults.ValidationProblem(
+ new Dictionary { [nameof(AdjustPointsRequest.UserId)] = ["Unbekannte Person."] }, title: "Eingaben prüfen");
+ }
+
+ var entry = new PointsLedgerEntry { UserId = target.Id, Points = request.Points, Reason = request.Reason.Trim() };
+ db.PointsLedger.Add(entry);
+ await db.SaveChangesAsync(ct);
+
+ var name = string.IsNullOrWhiteSpace(target.DisplayName) ? target.UserName : target.DisplayName;
+ await audit.WriteAsync(user.ActorName(), AuditActions.PointsAdjusted, "User", target.Id.ToString(), name,
+ $"{(request.Points > 0 ? "+" : string.Empty)}{request.Points} Punkte: {entry.Reason}", ct);
+ await notifier.PointsChangedAsync(ct);
+ return TypedResults.Ok(new PointsEntryDto(entry.Id, entry.UserId, entry.Points, entry.Reason, null, entry.CreatedAt));
+ }
+
+ // ---------- Hilfen ----------
+
+ private sealed record TransitionOutcome(TodoTask? NextInstance, bool PointsChanged);
+
+ /// Verlauf, Punkte (Gutschrift/Rückbuchung) und ggf. nächste Instanz zu einer Statusänderung vormerken.
+ private static async Task CompleteTransitionAsync(
+ AppDbContext db, TodoTask task, StatusTransition transition, TaskHistoryEntry history, FamilySettings settings,
+ TimeProvider timeProvider, CancellationToken ct)
+ {
+ db.TaskHistory.Add(history);
+ var pointsChanged = false;
+
+ if (transition.Completes && TaskWorkflow.AwardsPoints(task, settings.ChorePointsEnabled))
+ {
+ db.PointsLedger.Add(new PointsLedgerEntry
+ {
+ UserId = TaskWorkflow.PointsRecipient(task, task.CompletedBy ?? Guid.Empty),
+ Points = task.Points,
+ TaskId = task.Id,
+ Reason = Truncate($"Ämtchen „{task.Title}“"),
+ });
+ pointsChanged = true;
+ }
+ else if (transition.ReopensDone)
+ {
+ var credited = await db.PointsLedger.Where(p => p.TaskId == task.Id)
+ .GroupBy(p => p.UserId)
+ .Select(g => new { UserId = g.Key, Points = g.Sum(p => p.Points) })
+ .ToListAsync(ct);
+ foreach (var credit in credited.Where(c => c.Points != 0))
+ {
+ db.PointsLedger.Add(new PointsLedgerEntry
+ {
+ UserId = credit.UserId,
+ Points = -credit.Points,
+ TaskId = task.Id,
+ Reason = Truncate($"Rückbuchung: „{task.Title}“ wieder geöffnet"),
+ });
+ pointsChanged = true;
+ }
+ }
+
+ TodoTask? next = null;
+ if (transition.Completes && task.IsRecurring && task.NextInstanceId is null)
+ {
+ var zone = settings.GetTimeZone();
+ var now = timeProvider.GetUtcNow();
+ if (TaskSchedule.NextDueDate(task, task.CompletedAt ?? now, now, zone) is { } nextDue)
+ {
+ next = TaskSchedule.CreateNextInstance(task, nextDue, zone);
+ task.NextInstanceId = next.Id;
+ db.Tasks.Add(next);
+ db.TaskHistory.Add(new TaskHistoryEntry
+ {
+ TaskId = next.Id,
+ Action = TaskHistoryAction.Created,
+ ToStatus = next.Status,
+ Note = "Nächste Wiederholung",
+ });
+ }
+ }
+
+ return new TransitionOutcome(next, pointsChanged);
+ }
+
+ private static async Task PublishAsync(
+ TaskNotifier notifier, TodoTask task, TaskDto before, TransitionOutcome outcome, CancellationToken ct)
+ {
+ var dto = ToDto(task);
+ await notifier.ChangedAsync(dto, before, ct);
+ TaskDto? next = null;
+ if (outcome.NextInstance is { } instance)
+ {
+ next = ToDto(instance);
+ await notifier.ChangedAsync(next, null, ct);
+ }
+
+ if (outcome.PointsChanged)
+ {
+ await notifier.PointsChangedAsync(ct);
+ }
+
+ return new TaskStatusResult(dto, next);
+ }
+
+ /// Objektbezogene Prüfungen, die der Validator nicht kennt: Rechte für Zuweisung/Ämtchen, Kategorie, Personen.
+ private static async Task<(ProblemHttpResult? Problem, ValidationProblem? Invalid)> CheckFieldsAsync(
+ ITaskFields fields, TodoTask? current, TaskActor actor, FamilySettings settings, AppDbContext db, CancellationToken ct)
+ {
+ var assignees = fields.AssignedToFamily ? [] : (fields.AssigneeIds ?? []).Distinct().ToList();
+ var assignmentUnchanged = current is not null
+ && current.AssignedToFamily == fields.AssignedToFamily
+ && current.IsChore == fields.IsChore
+ && current.Points == fields.Points
+ && current.AssigneeIds.Order().SequenceEqual(assignees.Order());
+ if (!assignmentUnchanged && TaskRules.CheckAssignment(actor, assignees, fields.AssignedToFamily, fields.IsChore, fields.Points) is { } error)
+ {
+ return (TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Keine Berechtigung", detail: error), null);
+ }
+
+ var category = Clean(fields.Category);
+ if (category is not null && category != current?.Category && !settings.TaskCategories.Contains(category, StringComparer.Ordinal))
+ {
+ return (null, Invalid(nameof(ITaskFields.Category), "Diese Kategorie ist in den Einstellungen nicht vorgesehen."));
+ }
+
+ var newAssignees = assignees.Except(current?.AssigneeIds ?? []).ToList();
+ if (newAssignees.Count > 0 && await db.Users.CountAsync(u => newAssignees.Contains(u.Id) && u.IsActive, ct) != newAssignees.Count)
+ {
+ return (null, Invalid(nameof(ITaskFields.AssigneeIds), "Mindestens eine Person ist unbekannt oder deaktiviert."));
+ }
+
+ return (null, null);
+ }
+
+ /// Übernimmt die Felder (Normalisierung, Zuweisung, Wiederholung, Checkliste) und berechnet Fälligkeit/Erinnerung.
+ internal static void Apply(TodoTask task, ITaskFields fields, TaskActor actor, TimeZoneInfo zone)
+ {
+ task.Title = fields.Title.Trim();
+ task.Notes = Clean(fields.Notes);
+ task.Priority = fields.Priority;
+ task.Category = Clean(fields.Category);
+ task.IsChore = fields.IsChore;
+ task.Points = fields.IsChore ? fields.Points : 0;
+ task.NeedsApproval = fields.IsChore && fields.NeedsApproval;
+ task.AssignedToFamily = fields.AssignedToFamily;
+ task.SetAssignees(fields.AssignedToFamily ? [] : fields.AssigneeIds ?? []);
+ task.IsPrivate = !fields.AssignedToFamily && (fields.IsPrivate || TaskRules.ForcePrivate(actor));
+
+ var rule = string.IsNullOrWhiteSpace(fields.RecurrenceRule) ? null : RecurrenceRule.Parse(fields.RecurrenceRule).ToString();
+ var ruleChanged = rule != task.RecurrenceRule || fields.RecurrenceMode != task.RecurrenceMode;
+ var dueChanged = fields.DueDate != task.DueDate || fields.DueTime != task.DueTime;
+ task.DueDate = fields.DueDate;
+ task.DueTime = fields.DueDate is null ? null : fields.DueTime;
+ task.ReminderMinutesBefore = fields.DueDate is null ? null : fields.ReminderMinutesBefore;
+ task.RecurrenceRule = rule;
+ task.RecurrenceMode = fields.RecurrenceMode;
+ if (rule is null)
+ {
+ task.SeriesStart = null;
+ }
+ else if (ruleChanged || dueChanged || task.SeriesStart is null)
+ {
+ // Neue oder geänderte Regel bzw. verschobene Fälligkeit: Serie beginnt ab dieser Instanz neu.
+ task.SeriesId ??= task.Id;
+ task.SeriesStart = task.DueDate;
+ if (ruleChanged)
+ {
+ task.SeriesIndex = 1;
+ }
+ }
+
+ TaskSchedule.Recalculate(task, zone);
+ ApplyChecklist(task, fields.Checklist ?? []);
+ }
+
+ private static void ApplyChecklist(TodoTask task, IReadOnlyList items)
+ {
+ var wanted = items.Where(i => !string.IsNullOrWhiteSpace(i.Text)).ToList();
+ var keep = wanted.Select(i => i.Id).ToHashSet();
+ task.Checklist.RemoveAll(c => !keep.Contains(c.Id));
+
+ for (var index = 0; index < wanted.Count; index++)
+ {
+ var item = wanted[index];
+ var existing = task.Checklist.SingleOrDefault(c => c.Id == item.Id);
+ if (existing is null)
+ {
+ // Neue Punkte bekommen immer eine Server-Id (fremde Ids könnten zu anderen Aufgaben gehören).
+ existing = new TaskChecklistItem { TaskId = task.Id };
+ task.Checklist.Add(existing);
+ }
+
+ existing.Text = item.Text.Trim();
+ existing.IsDone = item.IsDone;
+ existing.SortOrder = index;
+ }
+ }
+
+ private static IQueryable Query(AppDbContext db, bool tracking)
+ {
+ var query = db.Tasks.Include(t => t.Assignments).Include(t => t.Checklist).AsSplitQuery();
+ return tracking ? query : query.AsNoTracking();
+ }
+
+ private static async Task> UserNamesAsync(AppDbContext db, IEnumerable ids, CancellationToken ct)
+ {
+ var distinct = ids.OfType().Distinct().ToList();
+ var users = await db.Users.IgnoreQueryFilters().AsNoTracking()
+ .Where(u => distinct.Contains(u.Id))
+ .Select(u => new { u.Id, u.DisplayName, u.UserName })
+ .ToListAsync(ct);
+ return users.ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName);
+ }
+
+ private static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
+
+ private static string Truncate(string text) =>
+ text.Length <= PointsLedgerEntry.ReasonMaxLength ? text : text[..(PointsLedgerEntry.ReasonMaxLength - 1)] + "…";
+
+ private static ValidationProblem Invalid(string field, string message) =>
+ TypedResults.ValidationProblem(new Dictionary { [field] = [message] }, title: "Eingaben prüfen");
+
+ internal static TaskDto ToDto(TodoTask task) => new(
+ task.Id,
+ task.Title,
+ task.Notes,
+ task.DueDate,
+ task.DueTime,
+ task.DueAt,
+ task.ReminderMinutesBefore,
+ task.Priority,
+ task.Status,
+ task.Category,
+ task.IsPrivate,
+ task.IsChore,
+ task.Points,
+ task.NeedsApproval,
+ task.AssignedToFamily,
+ task.AssigneeIds,
+ task.RecurrenceRule,
+ task.RecurrenceMode,
+ task.SeriesId,
+ task.Checklist.OrderBy(c => c.SortOrder).Select(c => new TaskChecklistItemDto(c.Id, c.Text, c.IsDone)).ToList(),
+ task.CreatedBy,
+ task.CreatedAt,
+ task.UpdatedAt,
+ task.CompletedAt,
+ task.CompletedBy,
+ task.RowVersion);
+}
diff --git a/src/FamilyPlanner.Api/Features/Tasks/TaskNotifier.cs b/src/FamilyPlanner.Api/Features/Tasks/TaskNotifier.cs
new file mode 100644
index 0000000..ab98aa1
--- /dev/null
+++ b/src/FamilyPlanner.Api/Features/Tasks/TaskNotifier.cs
@@ -0,0 +1,73 @@
+using FamilyPlanner.Api.Hubs;
+using FamilyPlanner.Domain.Tasks;
+using FamilyPlanner.Infrastructure.Identity;
+using FamilyPlanner.Infrastructure.Persistence;
+using FamilyPlanner.Infrastructure.Settings;
+using FamilyPlanner.Shared.Tasks;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+
+namespace FamilyPlanner.Api.Features.Tasks;
+
+///
+/// Sendet Aufgaben-Änderungen nur an Benutzer, die die Aufgabe sehen dürfen (Gruppe user:{id}). Wer sie nach einer
+/// Änderung nicht mehr sehen darf (z. B. privat gestellt, Zuweisung entfernt), erhält TaskRemoved (ADR-0014).
+///
+public sealed class TaskNotifier(AppDbContext db, IHubContext hub, ISettingsProvider settingsProvider, TimeProvider timeProvider)
+{
+ private List? _audience;
+
+ public async Task ChangedAsync(TaskDto task, ITaskFacts? before, CancellationToken ct)
+ {
+ var audience = await AudienceAsync(ct);
+ var lost = before is null
+ ? []
+ : audience.Where(a => TaskRules.CanSee(before, a) && !TaskRules.CanSee(task, a)).Select(Group).ToList();
+ if (lost.Count > 0)
+ {
+ await hub.Clients.Groups(lost).SendAsync(TaskEvents.TaskRemoved, task.Id, ct);
+ }
+
+ var recipients = audience.Where(a => TaskRules.CanSee(task, a)).Select(Group).ToList();
+ if (recipients.Count > 0)
+ {
+ await hub.Clients.Groups(recipients).SendAsync(TaskEvents.TaskChanged, task, ct);
+ }
+ }
+
+ public async Task RemovedAsync(ITaskFacts before, CancellationToken ct)
+ {
+ var recipients = (await AudienceAsync(ct)).Where(a => TaskRules.CanSee(before, a)).Select(Group).ToList();
+ if (recipients.Count > 0)
+ {
+ await hub.Clients.Groups(recipients).SendAsync(TaskEvents.TaskRemoved, before.Id, ct);
+ }
+ }
+
+ /// Punktestände sind für alle Familienmitglieder sichtbar – Ereignis ohne Nutzdaten an die Familie.
+ public Task PointsChangedAsync(CancellationToken ct) =>
+ hub.Clients.Group(LiveHub.FamilyGroup).SendAsync(TaskEvents.PointsChanged, ct);
+
+ /// Aktive Benutzer mit ihren Aufgaben-Rechten (Familiengröße – einmal je Request geladen).
+ private async Task> AudienceAsync(CancellationToken ct)
+ {
+ if (_audience is not null)
+ {
+ return _audience;
+ }
+
+ var options = (await settingsProvider.GetAsync(ct)).ToPermissionOptions();
+ var roles = await db.GetRoleMapAsync(ct);
+ var now = timeProvider.GetUtcNow();
+ var ids = await db.Users.AsNoTracking()
+ .Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now))
+ .Select(u => u.Id)
+ .ToListAsync(ct);
+ _audience = ids
+ .Select(id => TaskActor.From(id, roles.TryGetValue(id, out var role) ? [role] : [], options))
+ .ToList();
+ return _audience;
+ }
+
+ private static string Group(TaskActor actor) => LiveHub.UserGroup(actor.UserId.ToString());
+}
diff --git a/src/FamilyPlanner.Api/Features/Tasks/TaskReminderService.cs b/src/FamilyPlanner.Api/Features/Tasks/TaskReminderService.cs
new file mode 100644
index 0000000..196fb2e
--- /dev/null
+++ b/src/FamilyPlanner.Api/Features/Tasks/TaskReminderService.cs
@@ -0,0 +1,119 @@
+using System.Globalization;
+using FamilyPlanner.Api.Features.Push;
+using FamilyPlanner.Domain;
+using FamilyPlanner.Domain.Common;
+using FamilyPlanner.Domain.Tasks;
+using FamilyPlanner.Infrastructure.Identity;
+using FamilyPlanner.Infrastructure.Persistence;
+using FamilyPlanner.Infrastructure.Settings;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace FamilyPlanner.Api.Features.Tasks;
+
+///
+/// Minimaler Erinnerungsdienst (ADR-0014): prüft jede Minute fällige Erinnerungen offener Aufgaben und sendet Web-Push an die
+/// zugewiesenen Personen (Familienaufgaben: alle Familienmitglieder ohne Gäste, sonst Ersteller). Ohne VAPID-Schlüssel inaktiv.
+/// Jede Erinnerung wird höchstens einmal versendet; stark verspätete (z. B. nach Ausfall) werden verworfen.
+///
+internal sealed class TaskReminderService(
+ IServiceScopeFactory scopeFactory, IOptions options, TimeProvider timeProvider, ILogger logger)
+ : BackgroundService
+{
+ private const int BatchSize = 50;
+ private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
+ private static readonly TimeSpan MaxDelay = TimeSpan.FromHours(6);
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!options.Value.IsConfigured)
+ {
+ logger.LogInformation("Web-Push ist nicht konfiguriert – Erinnerungen werden nicht versendet.");
+ return;
+ }
+
+ using var timer = new PeriodicTimer(Interval, timeProvider);
+ do
+ {
+ try
+ {
+ await SendDueRemindersAsync(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Erinnerungen konnten nicht versendet werden.");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+ }
+
+ internal async Task SendDueRemindersAsync(CancellationToken ct)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var sender = scope.ServiceProvider.GetRequiredService();
+ var settings = await scope.ServiceProvider.GetRequiredService().GetAsync(ct);
+ var zone = settings.GetTimeZone();
+ var now = timeProvider.GetUtcNow();
+
+ var due = await db.Tasks.Include(t => t.Assignments)
+ .Where(t => t.RemindAt != null && t.RemindAt <= now && t.ReminderSentAt == null
+ && (t.Status == TodoStatus.Open || t.Status == TodoStatus.InProgress))
+ .OrderBy(t => t.RemindAt)
+ .Take(BatchSize)
+ .ToListAsync(ct);
+ if (due.Count == 0)
+ {
+ return 0;
+ }
+
+ Dictionary? roles = null;
+ var sent = 0;
+ foreach (var task in due)
+ {
+ task.ReminderSentAt = now;
+ if (now - task.RemindAt > MaxDelay)
+ {
+ continue;
+ }
+
+ List recipients;
+ if (task.AssignedToFamily)
+ {
+ roles ??= await db.GetRoleMapAsync(ct);
+ recipients = roles.Where(r => r.Value != Roles.Guest).Select(r => r.Key).ToList();
+ }
+ else
+ {
+ recipients = task.AssigneeIds.Count > 0 ? [.. task.AssigneeIds] : task.CreatedBy is { } creator ? [creator] : [];
+ }
+
+ var subscriptions = await db.PushSubscriptions.Where(s => recipients.Contains(s.UserId)).ToListAsync(ct);
+ var message = new PushMessage($"Erinnerung: {task.Title}", Describe(task, zone, now), "/aufgaben", $"task-{task.Id}");
+ sent += (await PushEndpoints.SendAsync(db, sender, subscriptions, message, timeProvider, ct)).Sent;
+ }
+
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ // Aufgabe wurde gleichzeitig geändert – beim nächsten Durchlauf erneut prüfen.
+ }
+
+ return sent;
+ }
+
+ private static string Describe(TodoTask task, TimeZoneInfo zone, DateTimeOffset now)
+ {
+ if (task.DueDate is not { } date)
+ {
+ return "Diese Aufgabe ist noch offen.";
+ }
+
+ var day = date == FamilyTime.LocalDate(now, zone) ? "heute" : date.ToString("ddd, dd.MM.", German);
+ return task.DueTime is { } time ? $"Fällig {day} um {time.ToString("HH:mm", German)} Uhr." : $"Fällig {day}.";
+ }
+}
diff --git a/src/FamilyPlanner.Api/Hubs/LiveHub.cs b/src/FamilyPlanner.Api/Hubs/LiveHub.cs
index 70ef912..ce6eb9e 100644
--- a/src/FamilyPlanner.Api/Hubs/LiveHub.cs
+++ b/src/FamilyPlanner.Api/Hubs/LiveHub.cs
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
namespace FamilyPlanner.Api.Hubs;
///
-/// Live-Aktualisierungen (Einkaufsliste, Essenplan, später Aufgaben, Termine, Dashboard). Zugriff nur für angemeldete
+/// Live-Aktualisierungen (Einkaufsliste, Essenplan, Aufgaben über user:{id}, später Termine, Dashboard). Zugriff nur für angemeldete
/// Benutzer mit erledigtem Passwortwechsel (Fallback-Policy).
///
public sealed class LiveHub : Hub
diff --git a/src/FamilyPlanner.Api/Infrastructure/EndpointHelpers.cs b/src/FamilyPlanner.Api/Infrastructure/EndpointHelpers.cs
index 066e8b1..9185aec 100644
--- a/src/FamilyPlanner.Api/Infrastructure/EndpointHelpers.cs
+++ b/src/FamilyPlanner.Api/Infrastructure/EndpointHelpers.cs
@@ -1,5 +1,6 @@
using System.Security.Claims;
using FamilyPlanner.Domain.Authorization;
+using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared;
@@ -32,6 +33,13 @@ internal static class EndpointHelpers
return PermissionMatrix.For(permission, roles, settings.ToPermissionOptions());
}
+ /// Der Benutzer mit seinen Aufgaben-Rechten für objektbezogene Prüfungen (ADR-0014).
+ public static async Task TaskActorAsync(this ClaimsPrincipal user, ISettingsProvider settingsProvider)
+ {
+ var settings = await settingsProvider.GetAsync();
+ return TaskActor.From(user.UserId() ?? Guid.Empty, user.FindAll(ClaimTypes.Role).Select(c => c.Value), settings.ToPermissionOptions());
+ }
+
public static ProblemHttpResult Conflict(string detail) =>
TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "Aktion nicht möglich", detail: detail);
diff --git a/src/FamilyPlanner.Api/Program.cs b/src/FamilyPlanner.Api/Program.cs
index 14401b3..3a04794 100644
--- a/src/FamilyPlanner.Api/Program.cs
+++ b/src/FamilyPlanner.Api/Program.cs
@@ -1,7 +1,9 @@
using FamilyPlanner.Api.Features.Admin;
using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Api.Features.Meals;
+using FamilyPlanner.Api.Features.Push;
using FamilyPlanner.Api.Features.Shopping;
+using FamilyPlanner.Api.Features.Tasks;
using FamilyPlanner.Api.Features.Users;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Api.Hubs;
@@ -24,6 +26,12 @@ builder.Services.AddOpenApi();
builder.Services.AddValidatorsFromAssemblyContaining();
builder.Services.AddValidatorsFromAssemblyContaining();
builder.Services.AddSignalR();
+builder.Services.AddScoped();
+
+// Web-Push (ADR-0014): ohne VAPID-Schlüssel bleibt der Versand inaktiv.
+builder.Services.AddOptions().BindConfiguration(WebPushOptions.SectionName);
+builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30));
+builder.Services.AddHostedService();
builder.AddFamilyPlannerAuth();
builder.AddFamilyPlannerRateLimiting();
@@ -52,6 +60,8 @@ app.MapSettingsEndpoints();
app.MapUserDirectoryEndpoints();
app.MapShoppingEndpoints();
app.MapMealEndpoints();
+app.MapTaskEndpoints();
+app.MapPushEndpoints();
app.MapHub("/hubs/live");
app.Run();
diff --git a/src/FamilyPlanner.AppHost/AppHost.cs b/src/FamilyPlanner.AppHost/AppHost.cs
index ff3cde6..b9fd890 100644
--- a/src/FamilyPlanner.AppHost/AppHost.cs
+++ b/src/FamilyPlanner.AppHost/AppHost.cs
@@ -33,10 +33,19 @@ var api = builder.AddProject("api")
.WaitForCompletion(migrations)
.WithHttpHealthCheck("/health");
+// Web-Push-Erinnerungen (ADR-0014): VAPID-Schlüssel per User Secrets/Parameter setzen (`dotnet run tools/generate-vapid-keys.cs`).
+// Leer = Benachrichtigungen deaktiviert.
+var vapidPublicKey = builder.AddParameter("vapid-public-key", value: "", publishValueAsDefault: true);
+var vapidPrivateKey = builder.AddParameter("vapid-private-key", value: "", secret: true);
+var vapidSubject = builder.AddParameter("vapid-subject", value: "mailto:admin@localhost", publishValueAsDefault: true);
+api.WithEnvironment("WebPush__PublicKey", vapidPublicKey)
+ .WithEnvironment("WebPush__PrivateKey", vapidPrivateKey)
+ .WithEnvironment("WebPush__Subject", vapidSubject);
+
if (ephemeralDatabase)
{
// Tests melden sich mehrfach an – Rate Limit großzügiger.
- api.WithEnvironment("RateLimiting__LoginPermitLimit", "100");
+ api.WithEnvironment("RateLimiting__LoginPermitLimit", "1000");
}
builder.AddProject("web")
diff --git a/tools/generate-vapid-keys.cs b/tools/generate-vapid-keys.cs
new file mode 100644
index 0000000..ebbc0c4
--- /dev/null
+++ b/tools/generate-vapid-keys.cs
@@ -0,0 +1,18 @@
+// Erzeugt ein VAPID-Schlüsselpaar für Web-Push-Erinnerungen (ADR-0014).
+// Aufruf aus dem Repository-Wurzelverzeichnis: dotnet run tools/generate-vapid-keys.cs
+// Die Ausgabe enthält ein Geheimnis – nicht einchecken! Werte als Aspire-Parameter (User Secrets) bzw. in deploy/.env eintragen.
+using System.Security.Cryptography;
+
+using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+var parameters = key.ExportParameters(includePrivateParameters: true);
+byte[] publicKey = [0x04, .. parameters.Q.X!, .. parameters.Q.Y!];
+
+Console.WriteLine("# Docker (deploy/.env):");
+Console.WriteLine($"VAPID_PUBLIC_KEY={Base64Url(publicKey)}");
+Console.WriteLine($"VAPID_PRIVATE_KEY={Base64Url(parameters.D!)}");
+Console.WriteLine();
+Console.WriteLine("# Lokal mit Aspire (im Ordner src/FamilyPlanner.AppHost):");
+Console.WriteLine($"dotnet user-secrets set \"Parameters:vapid-public-key\" \"{Base64Url(publicKey)}\"");
+Console.WriteLine($"dotnet user-secrets set \"Parameters:vapid-private-key\" \"{Base64Url(parameters.D!)}\"");
+
+static string Base64Url(byte[] data) => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');