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('/', '_');