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:
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));
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user