feat: add task API with approval, points, live updates and web push

Minimal API slice /api/tasks (idempotent create with client ids, versioned updates, state-based status changes, checklist, parent approval/rejection, points overview and audited adjustments, recurring next instance), per-user SignalR broadcasts that respect private tasks, /api/push subscription endpoints, a dependency-free Web Push sender (RFC 8291/8292) and a minimal reminder service. VAPID keys via Aspire parameters and deploy/.env (tools/generate-vapid-keys.cs); integration test login limit raised.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
JannisandCopilot committed 2026-10-09 21:58:10 +02:00
1 parent 5e0458ac83
commit 18a0a06e06
12 files changed
+1309 -2

No files matched your search

+7
View File
@@ -13,6 +13,13 @@ ADMIN_PASSWORD=Erstes-Passwort-2026
ADMIN_EMAIL= ADMIN_EMAIL=
FAMILY_NAME=Familie Muster 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 ------------------------------------------------------------------- # --- Netzwerk -------------------------------------------------------------------
# Ohne Caddy: Port des Web-Containers auf dem Host (127.0.0.1 = nur lokal bzw. für eigenen Reverse Proxy). # 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 WEB_BIND=127.0.0.1
+4
View File
@@ -64,6 +64,10 @@ services:
<<: *connection-string <<: *connection-string
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
TZ: Europe/Berlin 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: depends_on:
migrations: migrations:
condition: service_completed_successfully condition: service_completed_successfully
@@ -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;
/// <summary>Web-Push vorbereiten: Konfiguration, Abos speichern/löschen, Testnachricht (ADR-0014). Alle angemeldeten Benutzer.</summary>
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<PushSubscriptionRequest>();
group.MapPost("/unsubscribe", UnsubscribeAsync).Validate<PushUnsubscribeRequest>();
group.MapPost("/test", SendTestAsync);
return app;
}
internal static Ok<PushConfigDto> GetConfig(IWebPushSender sender) =>
TypedResults.Ok(new PushConfigDto(sender.IsEnabled, sender.PublicKey));
/// <summary>Speichert das Abo des Geräts (Upsert über den Endpunkt; ein Gerät gehört immer dem zuletzt angemeldeten Benutzer).</summary>
internal static async Task<NoContent> 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<NoContent> 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<Results<Ok<PushTestResult>, 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));
}
/// <summary>Sendet an alle Abos; abgelaufene (404/410) bzw. dauerhaft fehlerhafte Abos werden entfernt.</summary>
internal static async Task<(int Sent, int Failed)> SendAsync(
AppDbContext db, IWebPushSender sender, IEnumerable<PushSubscription> 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);
}
}
@@ -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;
/// <summary>
/// VAPID-Konfiguration (Abschnitt <c>WebPush</c>). Schlüssel kommen aus Aspire-Parametern bzw. <c>.env</c> – nie aus dem Repository.
/// Erzeugen: <c>dotnet run tools/generate-vapid-keys.cs</c> (siehe README).
/// </summary>
public sealed class WebPushOptions
{
public const string SectionName = "WebPush";
/// <summary>Öffentlicher Schlüssel (P-256, unkomprimiert, Base64url).</summary>
public string? PublicKey { get; set; }
/// <summary>Privater Schlüssel (32 Byte, Base64url).</summary>
public string? PrivateKey { get; set; }
/// <summary>Kontakt für die Push-Dienste (<c>mailto:</c> oder <c>https:</c>).</summary>
public string Subject { get; set; } = "mailto:admin@localhost";
public bool IsConfigured => !string.IsNullOrWhiteSpace(PublicKey) && !string.IsNullOrWhiteSpace(PrivateKey);
}
/// <summary>Inhalt einer Benachrichtigung; der Service Worker zeigt sie an und öffnet beim Antippen <see cref="Url"/>.</summary>
public sealed record PushMessage(string Title, string Body, string? Url = null, string? Tag = null);
public enum PushSendResult
{
Sent,
/// <summary>Abo existiert beim Push-Dienst nicht mehr (404/410) – löschen.</summary>
Gone,
Failed,
}
public interface IWebPushSender
{
bool IsEnabled { get; }
string? PublicKey { get; }
Task<PushSendResult> SendAsync(PushSubscription subscription, PushMessage message, CancellationToken ct);
}
/// <summary>Web Push (RFC 8030) mit VAPID (RFC 8292) und verschlüsselten Nutzdaten (RFC 8291, aes128gcm) – ohne Fremdbibliothek.</summary>
internal sealed class WebPushSender(HttpClient http, IOptions<WebPushOptions> options, TimeProvider timeProvider, ILogger<WebPushSender> 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<PushSendResult> 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;
}
}
}
/// <summary>Kryptografie für Web Push: Inhaltsverschlüsselung nach RFC 8291 und VAPID-JWT (ES256).</summary>
internal static class WebPushCrypto
{
private const int RecordSize = 4096;
/// <summary>
/// Verschlüsselt <paramref name="plaintext"/> für ein Browser-Abo (Content-Encoding <c>aes128gcm</c>, ein Record).
/// <paramref name="senderKey"/> und <paramref name="salt"/> nur für Tests vorgeben.
/// </summary>
public static byte[] Encrypt(ReadOnlySpan<byte> 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;
}
/// <summary>Gegenstück zu <see cref="Encrypt"/> aus Sicht des Browsers (für Tests).</summary>
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];
}
/// <summary>VAPID-Token: <c>aud</c> = Origin des Push-Dienstes, gültig 12 Stunden, signiert mit ES256 (IEEE P1363).</summary>
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<string, object>
{
["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);
}
}
@@ -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;
/// <summary>Aufgaben und Ämtchen: CRUD, Status mit Verlauf, Eltern-Bestätigung, Punkte, Wiederholungen (ADR-0014).</summary>
public static class TaskEndpoints
{
/// <summary>Erledigte Aufgaben werden so viele Tage lang mitgeliefert (danach nur noch im Verlauf/der Datenbank).</summary>
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<CreateTaskRequest>();
group.MapPut("/{id:guid}", UpdateAsync).RequirePermission(Permission.TasksEdit).Validate<UpdateTaskRequest>();
group.MapDelete("/{id:guid}", DeleteAsync).RequirePermission(Permission.TasksEdit);
group.MapPost("/{id:guid}/status", SetStatusAsync).RequirePermission(Permission.TasksCompleteAssigned).Validate<SetTaskStatusRequest>();
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<RejectTaskRequest>();
group.MapGet("/points", GetPointsAsync).RequireAuthorization(Policies.FamilyMember);
group.MapPost("/points/adjust", AdjustPointsAsync).RequirePermission(Permission.TasksApprove).Validate<AdjustPointsRequest>();
return app;
}
// ---------- Lesen ----------
internal static async Task<Ok<List<TaskDto>>> 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<Results<Ok<TaskDetailDto>, 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<Results<Created<TaskDto>, Ok<TaskDto>, 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<Results<Ok<TaskDto>, NotFound, Conflict<TaskDto>, 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<Results<NoContent, NotFound, ForbidHttpResult>> 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<Results<Ok<TaskStatusResult>, 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<Results<Ok<TaskDto>, 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<Results<Ok<TaskStatusResult>, 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<Results<Ok<TaskStatusResult>, 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<Ok<PointsOverviewDto>> 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<Results<Ok<PointsEntryDto>, 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<string, string[]> { [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);
/// <summary>Verlauf, Punkte (Gutschrift/Rückbuchung) und ggf. nächste Instanz zu einer Statusänderung vormerken.</summary>
private static async Task<TransitionOutcome> 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<TaskStatusResult> 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);
}
/// <summary>Objektbezogene Prüfungen, die der Validator nicht kennt: Rechte für Zuweisung/Ämtchen, Kategorie, Personen.</summary>
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);
}
/// <summary>Übernimmt die Felder (Normalisierung, Zuweisung, Wiederholung, Checkliste) und berechnet Fälligkeit/Erinnerung.</summary>
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<TaskChecklistItemDto> 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<TodoTask> 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<Dictionary<Guid, string>> UserNamesAsync(AppDbContext db, IEnumerable<Guid?> ids, CancellationToken ct)
{
var distinct = ids.OfType<Guid>().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<string, string[]> { [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);
}
@@ -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;
/// <summary>
/// Sendet Aufgaben-Änderungen nur an Benutzer, die die Aufgabe sehen dürfen (Gruppe <c>user:{id}</c>). Wer sie nach einer
/// Änderung nicht mehr sehen darf (z. B. privat gestellt, Zuweisung entfernt), erhält <c>TaskRemoved</c> (ADR-0014).
/// </summary>
public sealed class TaskNotifier(AppDbContext db, IHubContext<LiveHub> hub, ISettingsProvider settingsProvider, TimeProvider timeProvider)
{
private List<TaskActor>? _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);
}
}
/// <summary>Punktestände sind für alle Familienmitglieder sichtbar – Ereignis ohne Nutzdaten an die Familie.</summary>
public Task PointsChangedAsync(CancellationToken ct) =>
hub.Clients.Group(LiveHub.FamilyGroup).SendAsync(TaskEvents.PointsChanged, ct);
/// <summary>Aktive Benutzer mit ihren Aufgaben-Rechten (Familiengröße – einmal je Request geladen).</summary>
private async Task<List<TaskActor>> 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());
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed class TaskReminderService(
IServiceScopeFactory scopeFactory, IOptions<WebPushOptions> options, TimeProvider timeProvider, ILogger<TaskReminderService> 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<int> SendDueRemindersAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sender = scope.ServiceProvider.GetRequiredService<IWebPushSender>();
var settings = await scope.ServiceProvider.GetRequiredService<ISettingsProvider>().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<Guid, string>? roles = null;
var sent = 0;
foreach (var task in due)
{
task.ReminderSentAt = now;
if (now - task.RemindAt > MaxDelay)
{
continue;
}
List<Guid> 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}.";
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
namespace FamilyPlanner.Api.Hubs; namespace FamilyPlanner.Api.Hubs;
/// <summary> /// <summary>
/// Live-Aktualisierungen (Einkaufsliste, Essenplan, später Aufgaben, Termine, Dashboard). Zugriff nur für angemeldete /// Live-Aktualisierungen (Einkaufsliste, Essenplan, Aufgaben über <c>user:{id}</c>, später Termine, Dashboard). Zugriff nur für angemeldete
/// Benutzer mit erledigtem Passwortwechsel (Fallback-Policy). /// Benutzer mit erledigtem Passwortwechsel (Fallback-Policy).
/// </summary> /// </summary>
public sealed class LiveHub : Hub public sealed class LiveHub : Hub
@@ -1,5 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using FamilyPlanner.Domain.Authorization; using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Infrastructure.Identity; using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Settings; using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared; using FamilyPlanner.Shared;
@@ -32,6 +33,13 @@ internal static class EndpointHelpers
return PermissionMatrix.For(permission, roles, settings.ToPermissionOptions()); return PermissionMatrix.For(permission, roles, settings.ToPermissionOptions());
} }
/// <summary>Der Benutzer mit seinen Aufgaben-Rechten für objektbezogene Prüfungen (ADR-0014).</summary>
public static async Task<TaskActor> 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) => public static ProblemHttpResult Conflict(string detail) =>
TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "Aktion nicht möglich", detail: detail); TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "Aktion nicht möglich", detail: detail);
+10
View File
@@ -1,7 +1,9 @@
using FamilyPlanner.Api.Features.Admin; using FamilyPlanner.Api.Features.Admin;
using FamilyPlanner.Api.Features.Auth; using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Api.Features.Meals; using FamilyPlanner.Api.Features.Meals;
using FamilyPlanner.Api.Features.Push;
using FamilyPlanner.Api.Features.Shopping; using FamilyPlanner.Api.Features.Shopping;
using FamilyPlanner.Api.Features.Tasks;
using FamilyPlanner.Api.Features.Users; using FamilyPlanner.Api.Features.Users;
using FamilyPlanner.Shared.Admin; using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Api.Hubs; using FamilyPlanner.Api.Hubs;
@@ -24,6 +26,12 @@ builder.Services.AddOpenApi();
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>(); builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddScoped<TaskNotifier>();
// Web-Push (ADR-0014): ohne VAPID-Schlüssel bleibt der Versand inaktiv.
builder.Services.AddOptions<WebPushOptions>().BindConfiguration(WebPushOptions.SectionName);
builder.Services.AddHttpClient<IWebPushSender, WebPushSender>(client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddHostedService<TaskReminderService>();
builder.AddFamilyPlannerAuth(); builder.AddFamilyPlannerAuth();
builder.AddFamilyPlannerRateLimiting(); builder.AddFamilyPlannerRateLimiting();
@@ -52,6 +60,8 @@ app.MapSettingsEndpoints();
app.MapUserDirectoryEndpoints(); app.MapUserDirectoryEndpoints();
app.MapShoppingEndpoints(); app.MapShoppingEndpoints();
app.MapMealEndpoints(); app.MapMealEndpoints();
app.MapTaskEndpoints();
app.MapPushEndpoints();
app.MapHub<LiveHub>("/hubs/live"); app.MapHub<LiveHub>("/hubs/live");
app.Run(); app.Run();
+10 -1
View File
@@ -33,10 +33,19 @@ var api = builder.AddProject<Projects.FamilyPlanner_Api>("api")
.WaitForCompletion(migrations) .WaitForCompletion(migrations)
.WithHttpHealthCheck("/health"); .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) if (ephemeralDatabase)
{ {
// Tests melden sich mehrfach an – Rate Limit großzügiger. // Tests melden sich mehrfach an – Rate Limit großzügiger.
api.WithEnvironment("RateLimiting__LoginPermitLimit", "100"); api.WithEnvironment("RateLimiting__LoginPermitLimit", "1000");
} }
builder.AddProject<Projects.FamilyPlanner_Web>("web") builder.AddProject<Projects.FamilyPlanner_Web>("web")
+18
View File
@@ -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('/', '_');