feat: add chat API with live events, typing relay and dashboard unread count

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 08:48:07 +02:00
1 parent 0678a0e460
commit 65d4f13773
6 files changed
+814 -4

No files matched your search

@@ -0,0 +1,129 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Chat;
/// <summary>Der handelnde Benutzer mit seinen aktuellen Chat-Rechten (Rollen aus dem Cookie, Einstellungen live).</summary>
public sealed record ChatActor(Guid UserId, IReadOnlyList<string> Roles, PermissionOptions Options)
{
public bool IsAdmin => Roles.Contains(Domain.Roles.Admin);
public bool CanFamilyChat => ChatRules.CanUseFamilyChat(Roles, Options);
public bool CanDirect => ChatRules.CanUseDirectMessages(Roles, Options);
}
/// <summary>
/// Zugriff auf Kanäle, Empfänger und Ungelesen-Zähler. Alle Prüfungen laufen bei jedem Aufruf gegen die aktuellen Rechte
/// (Rolle, Gast-Einstellung, aktiv/abgelaufen) – SignalR-Gruppen werden dafür bewusst nicht benutzt (ADR-0019).
/// </summary>
public sealed class ChatAccessService(AppDbContext db, ISettingsProvider settingsProvider, TimeProvider timeProvider)
{
public async Task<ChatActor> ActorAsync(ClaimsPrincipal user, CancellationToken ct = default)
{
var settings = await settingsProvider.GetAsync(ct);
return new ChatActor(
user.UserId() ?? Guid.Empty,
user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
settings.ToPermissionOptions());
}
/// <summary>Der Kanal, wenn der Benutzer ihn sehen darf – sonst <c>null</c> (die Api antwortet mit 404).</summary>
public async Task<ChatChannel?> ChannelAsync(ChatActor actor, Guid channelId, CancellationToken ct = default)
{
var channel = await db.ChatChannels.AsNoTracking().SingleOrDefaultAsync(c => c.Id == channelId, ct);
if (channel is null)
{
return null;
}
return channel.Kind switch
{
ChatChannelKind.Family => actor.CanFamilyChat ? channel : null,
_ => actor.CanDirect && await db.ChatMembers.AnyAsync(m => m.ChannelId == channelId && m.UserId == actor.UserId, ct) ? channel : null,
};
}
/// <summary>Alle Kanäle des Benutzers: Familienchat (falls erlaubt) und seine Direktkanäle (fremde leere Kanäle bleiben verborgen).</summary>
public async Task<List<ChatChannel>> VisibleChannelsAsync(ChatActor actor, CancellationToken ct = default)
{
var channels = new List<ChatChannel>();
if (actor.CanFamilyChat && await db.ChatChannels.AsNoTracking().SingleOrDefaultAsync(c => c.Kind == ChatChannelKind.Family, ct) is { } family)
{
channels.Add(family);
}
if (actor.CanDirect)
{
channels.AddRange(await (from channel in db.ChatChannels.AsNoTracking()
join member in db.ChatMembers on channel.Id equals member.ChannelId
where member.UserId == actor.UserId && channel.Kind == ChatChannelKind.Direct
&& (channel.LastMessageAt != null || channel.CreatedBy == actor.UserId)
select channel).ToListAsync(ct));
}
return channels;
}
/// <summary>Aktive Benutzer, die den Kanal jetzt sehen dürfen (Empfänger für Live-Ereignisse und Typing).</summary>
public async Task<List<Guid>> RecipientsAsync(ChatChannel channel, CancellationToken ct = default)
{
var options = (await settingsProvider.GetAsync(ct)).ToPermissionOptions();
var roles = await db.GetRoleMapAsync(ct);
var now = timeProvider.GetUtcNow();
IQueryable<AppUser> users = db.Users.AsNoTracking().Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now));
if (channel.Kind == ChatChannelKind.Direct)
{
var members = db.ChatMembers.Where(m => m.ChannelId == channel.Id).Select(m => m.UserId);
users = users.Where(u => members.Contains(u.Id));
}
var ids = await users.Select(u => u.Id).ToListAsync(ct);
return ids
.Where(id => roles.TryGetValue(id, out var role)
&& (channel.Kind == ChatChannelKind.Family ? ChatRules.CanUseFamilyChat([role], options) : ChatRules.CanUseDirectMessages([role], options)))
.ToList();
}
/// <summary>Darf der Benutzer in diesem Kanal schreiben? Bei Direktnachrichten muss die andere Person noch erreichbar sein.</summary>
public async Task<bool> CanSendAsync(ChatActor actor, ChatChannel channel, CancellationToken ct = default) =>
channel.Kind == ChatChannelKind.Family || (await RecipientsAsync(channel, ct)).Count == 2;
/// <summary>Ungelesene Nachrichten je Kanal in einer Abfrage (ohne eigene und gelöschte Nachrichten).</summary>
public async Task<Dictionary<Guid, int>> UnreadCountsAsync(ChatActor actor, IReadOnlyCollection<Guid> channelIds, CancellationToken ct = default)
{
if (channelIds.Count == 0)
{
return [];
}
var baseline = await db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => u.Id == actor.UserId).Select(u => u.CreatedAt).SingleOrDefaultAsync(ct);
var userId = actor.UserId;
var counts = await (from message in db.ChatMessages.AsNoTracking().Where(m => channelIds.Contains(m.ChannelId) && m.SenderId != userId)
join member in db.ChatMembers.Where(m => m.UserId == userId) on message.ChannelId equals member.ChannelId into members
from member in members.DefaultIfEmpty()
where message.CreatedAt > (member == null ? baseline : member.LastReadAt)
group message by message.ChannelId into grouped
select new { ChannelId = grouped.Key, Count = grouped.Count() })
.ToListAsync(ct);
return counts.ToDictionary(c => c.ChannelId, c => c.Count);
}
/// <summary>Summe aller ungelesenen Nachrichten (Navigation, Dashboard).</summary>
public async Task<int> TotalUnreadAsync(ClaimsPrincipal user, CancellationToken ct = default)
{
var actor = await ActorAsync(user, ct);
var channels = await VisibleChannelsAsync(actor, ct);
return (await UnreadCountsAsync(actor, channels.Select(c => c.Id).ToList(), ct)).Values.Sum();
}
}
@@ -0,0 +1,562 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Chat;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Chat;
/// <summary>Familienchat und Direktnachrichten: Kanäle, Verlauf mit Paging, Senden, Bilder, Reaktionen, Gelesen-Stand (ADR-0019).</summary>
public static class ChatEndpoints
{
public static IEndpointRouteBuilder MapChatEndpoints(this IEndpointRouteBuilder app)
{
// Rechte je Kanal (Familienchat vs. Direktnachrichten, Gast-Einstellung) prüft ChatAccessService bei jedem Aufruf.
var group = app.MapGroup("/api/chat").WithTags("Chat");
group.MapGet("/channels", GetChannelsAsync);
group.MapPost("/direct", StartDirectAsync).Validate<StartDirectChatRequest>();
group.MapGet("/channels/{id:guid}/messages", GetMessagesAsync);
group.MapPost("/channels/{id:guid}/messages", SendAsync).Validate<SendChatMessageRequest>();
group.MapPost("/channels/{id:guid}/images", SendImageAsync)
.DisableAntiforgery()
.WithMetadata(new RequestSizeLimitAttribute(ChatRules.MaxImageBytes + 256 * 1024));
group.MapPost("/channels/{id:guid}/read", MarkReadAsync);
group.MapDelete("/messages/{id:guid}", DeleteAsync);
group.MapPut("/messages/{id:guid}/reactions", SetReactionAsync).Validate<SetChatReactionRequest>();
group.MapGet("/attachments/{id:guid}", GetAttachmentAsync);
return app;
}
// ---------- Kanäle ----------
internal static async Task<Ok<ChatChannelListDto>> GetChannelsAsync(
ClaimsPrincipal user, AppDbContext db, ChatAccessService access, TimeProvider timeProvider, CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
var channels = await access.VisibleChannelsAsync(actor, ct);
var ids = channels.Select(c => c.Id).ToList();
var unread = await access.UnreadCountsAsync(actor, ids, ct);
var directIds = channels.Where(c => c.Kind == ChatChannelKind.Direct).Select(c => c.Id).ToList();
var partners = await db.ChatMembers.AsNoTracking()
.Where(m => directIds.Contains(m.ChannelId) && m.UserId != actor.UserId)
.Select(m => new { m.ChannelId, m.UserId })
.ToDictionaryAsync(m => m.ChannelId, m => m.UserId, ct);
var partnerInfo = await PartnerInfoAsync(db, partners.Values, timeProvider.GetUtcNow(), ct);
var previews = await db.ChatMessages.AsNoTracking()
.Where(m => ids.Contains(m.ChannelId))
.GroupBy(m => m.ChannelId)
.Select(g => g.OrderByDescending(m => m.CreatedAt).ThenByDescending(m => m.Id)
.Select(m => new { m.ChannelId, m.SenderId, m.Text, m.HasImage, m.CreatedAt }).First())
.ToDictionaryAsync(m => m.ChannelId, m => new ChatPreviewDto(m.SenderId, m.Text, m.HasImage, m.CreatedAt), ct);
var dtos = channels
.Select(c =>
{
Guid? partnerId = partners.TryGetValue(c.Id, out var p) ? p : null;
var info = partnerId is { } pid ? partnerInfo.GetValueOrDefault(pid) : null;
return new ChatChannelDto(
c.Id,
c.Kind,
c.Kind == ChatChannelKind.Family ? "Familienchat" : info?.Name ?? "Unbekannt",
partnerId,
c.Kind == ChatChannelKind.Family || (info?.Reachable ?? false),
unread.GetValueOrDefault(c.Id),
c.LastMessageAt,
previews.GetValueOrDefault(c.Id));
})
.OrderBy(c => c.Kind)
.ThenByDescending(c => c.LastMessageAt)
.ThenBy(c => c.Title, StringComparer.CurrentCultureIgnoreCase)
.ToList();
return TypedResults.Ok(new ChatChannelListDto(dtos, dtos.Sum(c => c.UnreadCount)));
}
internal static async Task<Results<Ok<ChatChannelDto>, Created<ChatChannelDto>, NotFound, ForbidHttpResult, ProblemHttpResult>> StartDirectAsync(
StartDirectChatRequest request,
ClaimsPrincipal user,
AppDbContext db,
ChatAccessService access,
TimeProvider timeProvider,
CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
if (!actor.CanDirect)
{
return TypedResults.Forbid();
}
if (request.UserId == actor.UserId)
{
return EndpointHelpers.Conflict("Du kannst dir nicht selbst schreiben.");
}
var now = timeProvider.GetUtcNow();
var partner = (await PartnerInfoAsync(db, [request.UserId], now, ct)).GetValueOrDefault(request.UserId);
if (partner is null || !partner.Reachable)
{
return TypedResults.NotFound();
}
// Kinder dürfen nur Familienmitgliedern schreiben, nie Gästen (ADR-0019).
if (!ChatRules.CanDirectMessage(actor.Roles.FirstOrDefault() ?? string.Empty, partner.Role, actor.Options))
{
return TypedResults.Forbid();
}
var key = ChatRules.DirectKey(actor.UserId, request.UserId);
var channel = await db.ChatChannels.AsNoTracking().SingleOrDefaultAsync(c => c.DirectKey == key, ct);
var created = false;
if (channel is null)
{
channel = new ChatChannel { Kind = ChatChannelKind.Direct, DirectKey = key };
db.ChatChannels.Add(channel);
db.ChatMembers.AddRange(
new ChatMember { ChannelId = channel.Id, UserId = actor.UserId, LastReadAt = now },
new ChatMember { ChannelId = channel.Id, UserId = request.UserId, LastReadAt = now });
try
{
await db.SaveChangesAsync(ct);
created = true;
}
catch (DbUpdateException)
{
// Gleichzeitig vom Gegenüber angelegt: den vorhandenen Kanal verwenden.
db.ChangeTracker.Clear();
channel = await db.ChatChannels.AsNoTracking().SingleAsync(c => c.DirectKey == key, ct);
}
}
var dto = new ChatChannelDto(channel.Id, channel.Kind, partner.Name, request.UserId, true, 0, channel.LastMessageAt, null);
return created ? TypedResults.Created($"/api/chat/channels/{channel.Id}/messages", dto) : TypedResults.Ok(dto);
}
// ---------- Verlauf ----------
internal static async Task<Results<Ok<ChatMessagePageDto>, NotFound, ValidationProblem>> GetMessagesAsync(
Guid id,
Guid? before,
int? take,
ClaimsPrincipal user,
AppDbContext db,
ChatAccessService access,
CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
if (await access.ChannelAsync(actor, id, ct) is null)
{
return TypedResults.NotFound();
}
var size = ChatRules.PageSize(take);
var query = db.ChatMessages.AsNoTracking().Where(m => m.ChannelId == id);
if (before is { } cursorId)
{
var cursor = await db.ChatMessages.IgnoreQueryFilters().AsNoTracking()
.Where(m => m.Id == cursorId && m.ChannelId == id)
.Select(m => new { m.CreatedAt, m.Id })
.SingleOrDefaultAsync(ct);
if (cursor is null)
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]> { ["before"] = ["Unbekannte Nachricht."] }, title: "Eingaben prüfen");
}
// Server-Zeit mit Id als Tiebreak: Client-Ids sind über Geräte hinweg nicht zeitlich geordnet.
query = query.Where(m => m.CreatedAt < cursor.CreatedAt || (m.CreatedAt == cursor.CreatedAt && m.Id.CompareTo(cursor.Id) < 0));
}
var page = await query.OrderByDescending(m => m.CreatedAt).ThenByDescending(m => m.Id).Take(size + 1).ToListAsync(ct);
var hasMore = page.Count > size;
var messages = page.Take(size).Reverse().ToList();
var readStates = await db.ChatMembers.AsNoTracking().Where(m => m.ChannelId == id)
.Select(m => new ChatReadStateDto(m.UserId, m.LastReadAt)).ToListAsync(ct);
return TypedResults.Ok(new ChatMessagePageDto(await ToDtosAsync(db, messages, ct), hasMore, readStates));
}
// ---------- Senden ----------
internal static async Task<Results<Created<ChatMessageDto>, Ok<ChatMessageDto>, NoContent, NotFound, ProblemHttpResult, ValidationProblem>> SendAsync(
Guid id,
SendChatMessageRequest request,
ClaimsPrincipal user,
AppDbContext db,
ChatAccessService access,
ChatNotifier notifier,
TimeProvider timeProvider,
CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
var channel = await access.ChannelAsync(actor, id, ct);
if (channel is null)
{
return TypedResults.NotFound();
}
// Idempotenz: dieselbe Client-Id erneut gesendet (Netzfehler, Doppelklick).
if (await RepeatedAsync(db, request.Id, id, actor.UserId, ct) is { } repeated)
{
return repeated;
}
if (!await access.CanSendAsync(actor, channel, ct))
{
return EndpointHelpers.Conflict("Diese Person ist nicht mehr erreichbar.");
}
var message = new ChatMessage { Id = request.Id, ChannelId = id, SenderId = actor.UserId, Text = ChatRules.NormalizeText(request.Text) };
return await SaveMessageAsync(db, access, notifier, timeProvider, actor, channel, message, null, ct);
}
internal static async Task<Results<Created<ChatMessageDto>, Ok<ChatMessageDto>, NoContent, NotFound, ProblemHttpResult, ValidationProblem>> SendImageAsync(
Guid id,
IFormFile? file,
[FromForm] Guid? messageId,
[FromForm] string? text,
ClaimsPrincipal user,
AppDbContext db,
ChatAccessService access,
ChatNotifier notifier,
TimeProvider timeProvider,
CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
var channel = await access.ChannelAsync(actor, id, ct);
if (channel is null)
{
return TypedResults.NotFound();
}
var newId = messageId ?? Guid.CreateVersion7();
if (await RepeatedAsync(db, newId, id, actor.UserId, ct) is { } repeated)
{
return repeated;
}
if (file is null || file.Length > ChatRules.MaxImageBytes)
{
return Invalid("File", file is null ? "Bitte ein Bild auswählen." : $"Das Bild ist zu groß (höchstens {ChatRules.MaxImageBytes / 1024 / 1024} MB).");
}
byte[] data;
await using (var stream = file.OpenReadStream())
{
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, ct);
data = buffer.ToArray();
}
var (contentType, error) = ChatRules.ValidateImage(data);
if (contentType is null)
{
return Invalid("File", error!);
}
var caption = ChatRules.NormalizeText(text);
if (caption?.Length > ChatMessage.TextMaxLength)
{
return Invalid("Text", $"Die Bildunterschrift darf höchstens {ChatMessage.TextMaxLength} Zeichen haben.");
}
if (!await access.CanSendAsync(actor, channel, ct))
{
return EndpointHelpers.Conflict("Diese Person ist nicht mehr erreichbar.");
}
// Der erkannte Typ wird gespeichert, nie die Angabe des Clients.
var message = new ChatMessage { Id = newId, ChannelId = id, SenderId = actor.UserId, Text = caption, HasImage = true };
return await SaveMessageAsync(db, access, notifier, timeProvider, actor, channel, message, new ChatAttachment { Id = newId, ContentType = contentType, Data = data }, ct);
}
private static async Task<Results<Created<ChatMessageDto>, Ok<ChatMessageDto>, NoContent, NotFound, ProblemHttpResult, ValidationProblem>> SaveMessageAsync(
AppDbContext db,
ChatAccessService access,
ChatNotifier notifier,
TimeProvider timeProvider,
ChatActor actor,
ChatChannel channel,
ChatMessage message,
ChatAttachment? attachment,
CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
db.ChatMessages.Add(message);
if (attachment is not null)
{
db.ChatAttachments.Add(attachment);
}
// Kanalreihenfolge (nur zur Sortierung) und eigener Gelesen-Stand: wer schreibt, hat den Verlauf gelesen.
await db.ChatChannels.Where(c => c.Id == channel.Id).ExecuteUpdateAsync(s => s.SetProperty(c => c.LastMessageAt, now), ct);
await AdvanceReadAsync(db, channel.Id, actor.UserId, now, ct);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Gleichzeitig mit derselben Id gesendet (doppelt abgeschickt): vorhandene Nachricht zurückgeben.
db.ChangeTracker.Clear();
if (await RepeatedAsync(db, message.Id, channel.Id, actor.UserId, ct) is { } repeated)
{
return repeated;
}
throw;
}
var dto = (await ToDtosAsync(db, [message], ct))[0];
await notifier.MessageAsync(channel, dto, ct);
return TypedResults.Created($"/api/chat/channels/{channel.Id}/messages", dto);
}
private static async Task<Results<Created<ChatMessageDto>, Ok<ChatMessageDto>, NoContent, NotFound, ProblemHttpResult, ValidationProblem>?> RepeatedAsync(
AppDbContext db, Guid messageId, Guid channelId, Guid senderId, CancellationToken ct)
{
var existing = await db.ChatMessages.IgnoreQueryFilters().AsNoTracking().SingleOrDefaultAsync(m => m.Id == messageId, ct);
if (existing is null)
{
return null;
}
if (existing.ChannelId != channelId || existing.SenderId != senderId)
{
return EndpointHelpers.Conflict("Diese Nachrichten-Id ist bereits vergeben.");
}
// Gelöscht bleibt gelöscht.
return existing.IsDeleted ? TypedResults.NoContent() : TypedResults.Ok((await ToDtosAsync(db, [existing], ct))[0]);
}
// ---------- Gelesen ----------
internal static async Task<Results<NoContent, NotFound>> MarkReadAsync(
Guid id,
MarkChatReadRequest? request,
ClaimsPrincipal user,
AppDbContext db,
ChatAccessService access,
ChatNotifier notifier,
TimeProvider timeProvider,
CancellationToken ct)
{
var actor = await access.ActorAsync(user, ct);
var channel = await access.ChannelAsync(actor, id, ct);
if (channel is null)
{
return TypedResults.NotFound();
}
// Bis zur angezeigten Nachricht (nicht „jetzt“): Nachrichten, die erst eintreffen, bleiben ungelesen.
var upTo = timeProvider.GetUtcNow();
if (request?.UpToMessageId is { } messageId)
{
var at = await db.ChatMessages.IgnoreQueryFilters().AsNoTracking()
.Where(m => m.Id == messageId && m.ChannelId == id).Select(m => (DateTimeOffset?)m.CreatedAt).SingleOrDefaultAsync(ct);
if (at is null)
{
return TypedResults.NotFound();
}
upTo = at.Value;
}
var current = await AdvanceReadAsync(db, id, actor.UserId, upTo, ct);
await db.SaveChangesAsync(ct);
await notifier.ReadAsync(channel, actor.UserId, current, ct);
return TypedResults.NoContent();
}
/// <summary>Setzt den Gelesen-Stand nie zurück (monoton). Liefert den gültigen Stand; speichert nicht selbst.</summary>
private static async Task<DateTimeOffset> AdvanceReadAsync(AppDbContext db, Guid channelId, Guid userId, DateTimeOffset to, CancellationToken ct)
{
var member = await db.ChatMembers.SingleOrDefaultAsync(m => m.ChannelId == channelId && m.UserId == userId, ct);
if (member is null)
{
db.ChatMembers.Add(new ChatMember { ChannelId = channelId, UserId = userId, LastReadAt = to });
return to;
}
if (to > member.LastReadAt)
{
member.LastReadAt = to;
}
return member.LastReadAt;
}
// ---------- Löschen, Reaktionen, Bilder ----------
internal static async Task<Results<NoContent, NotFound, ForbidHttpResult>> DeleteAsync(
Guid id, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, ChatNotifier notifier, CancellationToken ct)
{
var message = await db.ChatMessages.SingleOrDefaultAsync(m => m.Id == id, ct);
if (message is null)
{
return TypedResults.NoContent(); // idempotent
}
var actor = await access.ActorAsync(user, ct);
var channel = await access.ChannelAsync(actor, message.ChannelId, ct);
if (channel is null)
{
return TypedResults.NotFound();
}
if (!ChatRules.CanDelete(message.SenderId, actor.UserId, actor.IsAdmin, channel.Kind))
{
return TypedResults.Forbid();
}
db.ChatMessages.Remove(message); // Soft Delete über den Interceptor
await db.SaveChangesAsync(ct);
// Bild und Reaktionen gehören nur zur sichtbaren Nachricht: Bytes sofort entfernen.
await db.ChatAttachments.Where(a => a.Id == id).ExecuteDeleteAsync(ct);
await db.ChatReactions.Where(r => r.MessageId == id).ExecuteDeleteAsync(ct);
await notifier.RemovedAsync(channel, id, ct);
return TypedResults.NoContent();
}
internal static async Task<Results<Ok<List<ChatReactionDto>>, NotFound>> SetReactionAsync(
Guid id,
SetChatReactionRequest request,
ClaimsPrincipal user,
AppDbContext db,
ChatAccessService access,
ChatNotifier notifier,
CancellationToken ct)
{
var message = await db.ChatMessages.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id, ct);
var actor = await access.ActorAsync(user, ct);
var channel = message is null ? null : await access.ChannelAsync(actor, message.ChannelId, ct);
if (message is null || channel is null)
{
return TypedResults.NotFound();
}
var existing = await db.ChatReactions.SingleOrDefaultAsync(r => r.MessageId == id && r.UserId == actor.UserId && r.Emoji == request.Emoji, ct);
if (request.Active && existing is null)
{
db.ChatReactions.Add(new ChatReaction { MessageId = id, UserId = actor.UserId, Emoji = request.Emoji });
}
else if (!request.Active && existing is not null)
{
db.ChatReactions.Remove(existing);
}
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear(); // gleichzeitig gesetzt – der Zustand stimmt bereits
}
var reactions = (await ReactionsAsync(db, [id], ct)).GetValueOrDefault(id) ?? [];
await notifier.ReactionsAsync(channel, new ChatReactionsChangedDto(channel.Id, id, reactions), ct);
return TypedResults.Ok(reactions);
}
internal static async Task<Results<FileContentHttpResult, NotFound>> GetAttachmentAsync(
Guid id, ClaimsPrincipal user, AppDbContext db, ChatAccessService access, HttpContext context, CancellationToken ct)
{
var channelId = await db.ChatMessages.AsNoTracking().Where(m => m.Id == id && m.HasImage).Select(m => (Guid?)m.ChannelId).SingleOrDefaultAsync(ct);
var actor = await access.ActorAsync(user, ct);
if (channelId is null || await access.ChannelAsync(actor, channelId.Value, ct) is null)
{
return TypedResults.NotFound();
}
var attachment = await db.ChatAttachments.AsNoTracking().SingleOrDefaultAsync(a => a.Id == id, ct);
if (attachment is null)
{
return TypedResults.NotFound();
}
// Nur für den angemeldeten Benutzer cachebar; Bilder einer Nachricht ändern sich nie.
context.Response.Headers.CacheControl = "private, max-age=86400, immutable";
context.Response.Headers.XContentTypeOptions = "nosniff";
return TypedResults.File(attachment.Data, attachment.ContentType);
}
// ---------- Hilfen ----------
private sealed record PartnerInfo(string Name, string Role, bool Reachable);
private static async Task<Dictionary<Guid, PartnerInfo>> PartnerInfoAsync(
AppDbContext db, IEnumerable<Guid> userIds, DateTimeOffset now, CancellationToken ct)
{
var ids = userIds.Distinct().ToList();
if (ids.Count == 0)
{
return [];
}
var roles = await db.GetRoleMapAsync(ct);
var users = await db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.DisplayName, u.UserName, u.IsActive, u.IsDeleted, u.ExpiresAt })
.ToListAsync(ct);
return users.ToDictionary(
u => u.Id,
u => new PartnerInfo(
string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName,
roles.GetValueOrDefault(u.Id, string.Empty),
u.IsActive && !u.IsDeleted && (u.ExpiresAt == null || u.ExpiresAt > now)));
}
internal static async Task<List<ChatMessageDto>> ToDtosAsync(AppDbContext db, IReadOnlyList<ChatMessage> messages, CancellationToken ct)
{
if (messages.Count == 0)
{
return [];
}
var senderIds = messages.Select(m => m.SenderId).Distinct().ToList();
var names = (await db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => senderIds.Contains(u.Id))
.Select(u => new { u.Id, u.DisplayName, u.UserName })
.ToListAsync(ct))
.ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName);
var reactions = await ReactionsAsync(db, messages.Select(m => m.Id).ToList(), ct);
return messages.Select(m => new ChatMessageDto(
m.Id,
m.ChannelId,
m.SenderId,
names.GetValueOrDefault(m.SenderId, "Unbekannt"),
m.Text,
m.HasImage ? $"/api/chat/attachments/{m.Id}" : null,
m.CreatedAt,
reactions.GetValueOrDefault(m.Id) ?? [])).ToList();
}
private static async Task<Dictionary<Guid, List<ChatReactionDto>>> ReactionsAsync(AppDbContext db, IReadOnlyCollection<Guid> messageIds, CancellationToken ct)
{
var rows = await db.ChatReactions.AsNoTracking()
.Where(r => messageIds.Contains(r.MessageId))
.OrderBy(r => r.CreatedAt)
.Select(r => new { r.MessageId, r.Emoji, r.UserId })
.ToListAsync(ct);
return rows
.GroupBy(r => r.MessageId)
.ToDictionary(
g => g.Key,
g => g.GroupBy(r => r.Emoji).Select(e => new ChatReactionDto(e.Key, e.Select(r => r.UserId).ToList())).ToList());
}
private static ValidationProblem Invalid(string field, string message) =>
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [field] = [message] }, title: "Eingaben prüfen");
}
@@ -0,0 +1,86 @@
using System.Collections.Concurrent;
using FamilyPlanner.Api.Hubs;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Shared.Chat;
using Microsoft.AspNetCore.SignalR;
namespace FamilyPlanner.Api.Features.Chat;
/// <summary>
/// Sendet Chat-Ereignisse an die Gruppen <c>user:{id}</c> der aktuell berechtigten Empfänger (nie über die Sammelgruppe „family“,
/// damit entzogene Rechte sofort greifen). Bilddaten werden nie verschickt – nur die URL (ADR-0019).
/// </summary>
public sealed class ChatNotifier(ChatAccessService access, IHubContext<LiveHub> hub)
{
public async Task MessageAsync(ChatChannel channel, ChatMessageDto message, CancellationToken ct)
{
var groups = await GroupsAsync(channel, null, ct);
await hub.Clients.Groups(groups).SendAsync(ChatEvents.MessageReceived, message, ct);
}
public async Task RemovedAsync(ChatChannel channel, Guid messageId, CancellationToken ct)
{
var groups = await GroupsAsync(channel, null, ct);
await hub.Clients.Groups(groups).SendAsync(ChatEvents.MessageRemoved, channel.Id, messageId, ct);
}
public async Task ReactionsAsync(ChatChannel channel, ChatReactionsChangedDto change, CancellationToken ct)
{
var groups = await GroupsAsync(channel, null, ct);
await hub.Clients.Groups(groups).SendAsync(ChatEvents.ReactionsChanged, change, ct);
}
/// <summary>Gelesen-Stand: auch an den Leser selbst (seine anderen Geräte aktualisieren den Zähler).</summary>
public async Task ReadAsync(ChatChannel channel, Guid userId, DateTimeOffset readAt, CancellationToken ct)
{
var groups = await GroupsAsync(channel, null, ct);
await hub.Clients.Groups(groups).SendAsync(ChatEvents.ReadChanged, channel.Id, userId, readAt, ct);
}
public async Task TypingAsync(ChatChannel channel, Guid userId, CancellationToken ct)
{
var groups = await GroupsAsync(channel, userId, ct);
if (groups.Count > 0)
{
await hub.Clients.Groups(groups).SendAsync(ChatEvents.Typing, channel.Id, userId, ct);
}
}
private async Task<List<string>> GroupsAsync(ChatChannel channel, Guid? except, CancellationToken ct) =>
(await access.RecipientsAsync(channel, ct)).Where(id => id != except).Select(id => LiveHub.UserGroup(id.ToString())).ToList();
}
/// <summary>Begrenzt „tippt …“-Signale je Verbindung und Kanal (ein Signal alle 2 Sekunden).</summary>
public sealed class ChatTypingThrottle(TimeProvider timeProvider)
{
private static readonly TimeSpan Interval = TimeSpan.FromSeconds(2);
private readonly ConcurrentDictionary<(string Connection, Guid Channel), DateTimeOffset> _last = new();
public bool TryAcquire(string connectionId, Guid channelId)
{
var now = timeProvider.GetUtcNow();
var allowed = false;
_last.AddOrUpdate(
(connectionId, channelId),
_ =>
{
allowed = true;
return now;
},
(_, previous) =>
{
allowed = now - previous >= Interval;
return allowed ? now : previous;
});
return allowed;
}
public void Release(string connectionId)
{
foreach (var key in _last.Keys.Where(k => k.Connection == connectionId))
{
_last.TryRemove(key, out _);
}
}
}
@@ -1,5 +1,6 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
@@ -32,6 +33,7 @@ public static class DashboardEndpoints
ClaimsPrincipal user,
AppDbContext db,
CalendarAccessService calendarAccess,
ChatAccessService chatAccess,
ISettingsProvider settingsProvider,
TimeProvider timeProvider,
CancellationToken ct)
@@ -51,8 +53,9 @@ public static class DashboardEndpoints
var shopping = Allowed(Permission.ShoppingRead) ? await LoadShoppingAsync(db, Allowed(Permission.ShoppingWrite), ct) : null;
var (myTasks, familyTasks) = await LoadTasksAsync(user, db, settingsProvider, now, zone, ct);
// Der Chat folgt in Phase 8; bis dahin gibt es keine Nachrichten, die ungelesen sein könnten.
var chat = Allowed(Permission.FamilyChat) || Allowed(Permission.DirectMessages) ? new DashboardChatDto(0) : null;
var chat = Allowed(Permission.FamilyChat) || Allowed(Permission.DirectMessages)
? new DashboardChatDto(await chatAccess.TotalUnreadAsync(user, ct))
: null;
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat));
}
+27 -2
View File
@@ -1,13 +1,14 @@
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Domain;
using Microsoft.AspNetCore.SignalR;
namespace FamilyPlanner.Api.Hubs;
/// <summary>
/// Live-Aktualisierungen (Einkaufsliste, Essenplan, Aufgaben über <c>user:{id}</c>, Termine; das Dashboard lädt bei diesen Ereignissen neu). Zugriff nur für angemeldete
/// Live-Aktualisierungen (Einkaufsliste, Essenplan, Aufgaben über <c>user:{id}</c>, Termine, Chat; das Dashboard lädt bei diesen Ereignissen neu). Zugriff nur für angemeldete
/// Benutzer mit erledigtem Passwortwechsel (Fallback-Policy).
/// </summary>
public sealed class LiveHub : Hub
public sealed class LiveHub(ChatAccessService chatAccess, ChatNotifier chatNotifier, ChatTypingThrottle typingThrottle) : Hub
{
public const string FamilyGroup = "family";
@@ -35,5 +36,29 @@ public sealed class LiveHub : Hub
await base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
typingThrottle.Release(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// „Ich tippe“ im Chat. Nur für Kanäle, die der Benutzer jetzt sehen darf; die Ereignisse gehen an die anderen Empfänger
/// (nicht zurück an den Absender) und werden je Verbindung und Kanal gedrosselt.
/// </summary>
public async Task Typing(Guid channelId)
{
if (Context.User is not { } user || !typingThrottle.TryAcquire(Context.ConnectionId, channelId))
{
return;
}
var actor = await chatAccess.ActorAsync(user, Context.ConnectionAborted);
if (await chatAccess.ChannelAsync(actor, channelId, Context.ConnectionAborted) is { } channel)
{
await chatNotifier.TypingAsync(channel, actor.UserId, Context.ConnectionAborted);
}
}
public static string UserGroup(string userId) => $"user:{userId}";
}
+5
View File
@@ -1,6 +1,7 @@
using FamilyPlanner.Api.Features.Admin;
using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Dashboard;
using FamilyPlanner.Api.Features.Meals;
using FamilyPlanner.Api.Features.Push;
@@ -31,6 +32,9 @@ builder.Services.AddSignalR();
builder.Services.AddScoped<TaskNotifier>();
builder.Services.AddScoped<CalendarAccessService>();
builder.Services.AddScoped<CalendarNotifier>();
builder.Services.AddScoped<ChatAccessService>();
builder.Services.AddScoped<ChatNotifier>();
builder.Services.AddSingleton<ChatTypingThrottle>();
// Web-Push (ADR-0014): ohne VAPID-Schlüssel bleibt der Versand inaktiv.
builder.Services.AddOptions<WebPushOptions>().BindConfiguration(WebPushOptions.SectionName);
@@ -70,6 +74,7 @@ app.MapCalendarEndpoints();
app.MapCalendarAdminEndpoints();
app.MapFeedEndpoints();
app.MapDashboardEndpoints();
app.MapChatEndpoints();
app.MapHub<LiveHub>("/hubs/live");
app.Run();