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:
1 parent
5e0458ac83
commit
18a0a06e06
12 files changed
+1309
-2
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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('/', '_');
|
||||||
Reference in new issue
Block a user