feat: add chat domain, persistence and Phase8Chat migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fb8382765
commit
0678a0e460
9 files changed
+2505
No files matched your search
@@ -0,0 +1,79 @@
|
||||
using FamilyPlanner.Domain.Common;
|
||||
|
||||
namespace FamilyPlanner.Domain.Chat;
|
||||
|
||||
public enum ChatChannelKind
|
||||
{
|
||||
/// <summary>Der eine Familienchat (alle mit <c>FamilyChat</c>-Recht).</summary>
|
||||
Family = 0,
|
||||
|
||||
/// <summary>Direktnachrichten zwischen genau zwei Personen.</summary>
|
||||
Direct = 1,
|
||||
}
|
||||
|
||||
public sealed class ChatChannel : AuditableEntity
|
||||
{
|
||||
public const int DirectKeyMaxLength = 80;
|
||||
|
||||
public ChatChannelKind Kind { get; set; }
|
||||
|
||||
/// <summary>Nur bei Direktnachrichten: sortiertes Paar der Benutzer-Ids (<see cref="ChatRules.DirectKey"/>), je Paar höchstens ein Kanal.</summary>
|
||||
public string? DirectKey { get; set; }
|
||||
|
||||
/// <summary>Zeitpunkt der letzten Nachricht (Sortierung der Kanalliste); <c>null</c> solange nichts geschrieben wurde.</summary>
|
||||
public DateTimeOffset? LastMessageAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gelesen-Stand einer Person in einem Kanal. Bei Direktkanälen von Anfang an vorhanden, beim Familienchat beim ersten Lesen angelegt.
|
||||
/// Ohne Eintrag gilt als Basis der Zeitpunkt der Kontoerstellung.
|
||||
/// </summary>
|
||||
public sealed class ChatMember : AuditableEntity
|
||||
{
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>Alle Nachrichten bis zu diesem Zeitpunkt sind gelesen (wird nie zurückgesetzt).</summary>
|
||||
public DateTimeOffset LastReadAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChatMessage : AuditableEntity, ISoftDeletable
|
||||
{
|
||||
public const int TextMaxLength = 2000;
|
||||
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
public Guid SenderId { get; set; }
|
||||
|
||||
/// <summary>Text oder Bildunterschrift; bei reinen Bildnachrichten <c>null</c>.</summary>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>Das Bild liegt in <see cref="ChatAttachment"/> (gleiche Id wie die Nachricht).</summary>
|
||||
public bool HasImage { get; set; }
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public Guid? DeletedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Bild einer Nachricht (in der Datenbank, ADR-0009). Die Id ist die Id der Nachricht.</summary>
|
||||
public sealed class ChatAttachment : AuditableEntity
|
||||
{
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
|
||||
public byte[] Data { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChatReaction : AuditableEntity
|
||||
{
|
||||
public const int EmojiMaxLength = 16;
|
||||
|
||||
public Guid MessageId { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string Emoji { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Domain.Users;
|
||||
|
||||
namespace FamilyPlanner.Domain.Chat;
|
||||
|
||||
/// <summary>Regeln des Chats (ergänzen die Rollenmatrix, ADR-0019): Zugriff, Direktnachrichten, Text, Bilder, Reaktionen, Löschen.</summary>
|
||||
public static class ChatRules
|
||||
{
|
||||
public const int MaxImageBytes = 3 * 1024 * 1024;
|
||||
public const int DefaultPageSize = 30;
|
||||
public const int MaxPageSize = 100;
|
||||
|
||||
/// <summary>Die erlaubten Emoji-Reaktionen (feste Auswahl; das Schutzgut ist eine überschaubare Anzeige, keine Zeichensatzprüfung).</summary>
|
||||
public static IReadOnlyList<string> Reactions { get; } = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
||||
|
||||
public static bool IsAllowedReaction(string? emoji) => emoji is not null && Reactions.Contains(emoji, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Familienchat: Familie/Admin/Kinder immer, Gäste nur mit der Einstellung „Gäste dürfen den Familienchat nutzen“.</summary>
|
||||
public static bool CanUseFamilyChat(IEnumerable<string> roles, PermissionOptions? options = null) =>
|
||||
PermissionMatrix.For(Permission.FamilyChat, roles, options) != AccessLevel.None;
|
||||
|
||||
/// <summary>Direktnachrichten: nur Admin, Familie und Kinder (Gäste nie).</summary>
|
||||
public static bool CanUseDirectMessages(IEnumerable<string> roles, PermissionOptions? options = null) =>
|
||||
PermissionMatrix.For(Permission.DirectMessages, roles, options) != AccessLevel.None;
|
||||
|
||||
/// <summary>
|
||||
/// Eine Direktnachricht ist erlaubt, wenn beide Seiten Direktnachrichten nutzen dürfen. Die Einschränkung „Kinder nur an
|
||||
/// Familienmitglieder“ bedeutet damit: kein Kontakt zu Gästen (und umgekehrt); untereinander dürfen alle Familienmitglieder schreiben.
|
||||
/// </summary>
|
||||
public static bool CanDirectMessage(string senderRole, string recipientRole, PermissionOptions? options = null) =>
|
||||
CanUseDirectMessages([senderRole], options) && CanUseDirectMessages([recipientRole], options);
|
||||
|
||||
/// <summary>Stabiler Schlüssel eines Paares (unabhängig von der Reihenfolge).</summary>
|
||||
public static string DirectKey(Guid a, Guid b) => a.CompareTo(b) <= 0 ? $"{a:N}:{b:N}" : $"{b:N}:{a:N}";
|
||||
|
||||
/// <summary>Text normalisieren (Zeilenenden, Rand-Leerraum). Leer ergibt <c>null</c>.</summary>
|
||||
public static string? NormalizeText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ein Bild anhand der Signatur (PNG/JPEG/WebP) und der Größe. Liefert den erkannten Typ oder eine deutsche Fehlermeldung;
|
||||
/// die Angabe des Clients wird nicht vertraut.
|
||||
/// </summary>
|
||||
public static (string? ContentType, string? Error) ValidateImage(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length == 0)
|
||||
{
|
||||
return (null, "Die Datei ist leer.");
|
||||
}
|
||||
|
||||
if (data.Length > MaxImageBytes)
|
||||
{
|
||||
return (null, $"Das Bild ist zu groß (höchstens {MaxImageBytes / 1024 / 1024} MB).");
|
||||
}
|
||||
|
||||
return UserAvatar.DetectContentType(data) is { } type ? (type, null) : (null, "Bitte ein Bild im Format PNG, JPEG oder WebP senden.");
|
||||
}
|
||||
|
||||
/// <summary>Löschen: eigene Nachrichten immer; Administratoren zusätzlich Nachrichten im Familienchat (Moderation).</summary>
|
||||
public static bool CanDelete(Guid senderId, Guid actorId, bool actorIsAdmin, ChatChannelKind kind) =>
|
||||
senderId == actorId || (actorIsAdmin && kind == ChatChannelKind.Family);
|
||||
|
||||
/// <summary>Anzahl der Nachrichten einer Seite begrenzen.</summary>
|
||||
public static int PageSize(int? requested) => Math.Clamp(requested ?? DefaultPageSize, 1, MaxPageSize);
|
||||
|
||||
/// <summary>Ungelesen-Basis ohne Lesestand: die Kontoerstellung (neue Mitglieder starten ohne alte Nachrichten als ungelesen).</summary>
|
||||
public static DateTimeOffset UnreadBaseline(DateTimeOffset? lastReadAt, DateTimeOffset accountCreatedAt) => lastReadAt ?? accountCreatedAt;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using FamilyPlanner.Domain.Audit;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Domain.Meals;
|
||||
using FamilyPlanner.Domain.Push;
|
||||
using FamilyPlanner.Domain.Settings;
|
||||
@@ -54,6 +55,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
public DbSet<IcsFeed> IcsFeeds => Set<IcsFeed>();
|
||||
|
||||
public DbSet<ChatChannel> ChatChannels => Set<ChatChannel>();
|
||||
|
||||
public DbSet<ChatMember> ChatMembers => Set<ChatMember>();
|
||||
|
||||
public DbSet<ChatMessage> ChatMessages => Set<ChatMessage>();
|
||||
|
||||
public DbSet<ChatAttachment> ChatAttachments => Set<ChatAttachment>();
|
||||
|
||||
public DbSet<ChatReaction> ChatReactions => Set<ChatReaction>();
|
||||
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class ChatChannelConfiguration : IEntityTypeConfiguration<ChatChannel>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChatChannel> builder)
|
||||
{
|
||||
builder.ToTable("ChatChannels");
|
||||
builder.Property(c => c.Kind).HasConversion<string>().HasMaxLength(20);
|
||||
builder.Property(c => c.DirectKey).HasMaxLength(ChatChannel.DirectKeyMaxLength);
|
||||
|
||||
// Genau ein Familienchat und je Personenpaar höchstens ein Direktkanal.
|
||||
builder.HasIndex(c => c.Kind).IsUnique().HasFilter("[Kind] = 'Family'");
|
||||
builder.HasIndex(c => c.DirectKey).IsUnique().HasFilter("[DirectKey] IS NOT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatMemberConfiguration : IEntityTypeConfiguration<ChatMember>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChatMember> builder)
|
||||
{
|
||||
builder.ToTable("ChatMembers");
|
||||
builder.HasOne<ChatChannel>().WithMany().HasForeignKey(m => m.ChannelId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasIndex(m => new { m.ChannelId, m.UserId }).IsUnique();
|
||||
builder.HasIndex(m => m.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatMessageConfiguration : IEntityTypeConfiguration<ChatMessage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChatMessage> builder)
|
||||
{
|
||||
builder.ToTable("ChatMessages");
|
||||
builder.Property(m => m.Text).HasMaxLength(ChatMessage.TextMaxLength);
|
||||
builder.HasOne<ChatChannel>().WithMany().HasForeignKey(m => m.ChannelId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Seitenweises Laden: neueste zuerst, stabil über (CreatedAt, Id).
|
||||
builder.HasIndex(m => new { m.ChannelId, m.CreatedAt, m.Id });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatAttachmentConfiguration : IEntityTypeConfiguration<ChatAttachment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChatAttachment> builder)
|
||||
{
|
||||
builder.ToTable("ChatAttachments");
|
||||
builder.Property(a => a.ContentType).HasMaxLength(50).IsRequired();
|
||||
builder.Property(a => a.Data).IsRequired();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatReactionConfiguration : IEntityTypeConfiguration<ChatReaction>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChatReaction> builder)
|
||||
{
|
||||
builder.ToTable("ChatReactions");
|
||||
builder.Property(r => r.Emoji).HasMaxLength(ChatReaction.EmojiMaxLength).IsRequired();
|
||||
builder.HasOne<ChatMessage>().WithMany().HasForeignKey(r => r.MessageId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasIndex(r => new { r.MessageId, r.UserId, r.Emoji }).IsUnique();
|
||||
}
|
||||
}
|
||||
Generated
+1754
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase8Chat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatAttachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Data = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatAttachments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatChannels",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Kind = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
DirectKey = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: true),
|
||||
LastMessageAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatChannels", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatMembers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ChannelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
LastReadAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatMembers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChatMembers_ChatChannels_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "ChatChannels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ChannelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SenderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Text = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
HasImage = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChatMessages_ChatChannels_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "ChatChannels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatReactions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
MessageId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Emoji = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatReactions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChatReactions_ChatMessages_MessageId",
|
||||
column: x => x.MessageId,
|
||||
principalTable: "ChatMessages",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatChannels_DirectKey",
|
||||
table: "ChatChannels",
|
||||
column: "DirectKey",
|
||||
unique: true,
|
||||
filter: "[DirectKey] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatChannels_Kind",
|
||||
table: "ChatChannels",
|
||||
column: "Kind",
|
||||
unique: true,
|
||||
filter: "[Kind] = 'Family'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatMembers_ChannelId_UserId",
|
||||
table: "ChatMembers",
|
||||
columns: new[] { "ChannelId", "UserId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatMembers_UserId",
|
||||
table: "ChatMembers",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatMessages_ChannelId_CreatedAt_Id",
|
||||
table: "ChatMessages",
|
||||
columns: new[] { "ChannelId", "CreatedAt", "Id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatMessages_IsDeleted",
|
||||
table: "ChatMessages",
|
||||
column: "IsDeleted");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChatReactions_MessageId_UserId_Emoji",
|
||||
table: "ChatReactions",
|
||||
columns: new[] { "MessageId", "UserId", "Emoji" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatAttachments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatMembers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatReactions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatChannels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,204 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("IcsFeeds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<byte[]>("Data")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ChatAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatChannel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("DirectKey")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DirectKey")
|
||||
.IsUnique()
|
||||
.HasFilter("[DirectKey] IS NOT NULL");
|
||||
|
||||
b.HasIndex("Kind")
|
||||
.IsUnique()
|
||||
.HasFilter("[Kind] = 'Family'");
|
||||
|
||||
b.ToTable("ChatChannels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("LastReadAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("ChannelId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatMembers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("HasImage")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted");
|
||||
|
||||
b.HasIndex("ChannelId", "CreatedAt", "Id");
|
||||
|
||||
b.ToTable("ChatMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatReaction", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Emoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MessageId", "UserId", "Emoji")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatReactions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Meals.Dish", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1422,6 +1620,33 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatMember", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Chat.ChatChannel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatMessage", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Chat.ChatChannel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Chat.ChatReaction", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Chat.ChatMessage", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MessageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FamilyPlanner.Domain.Shopping.ShoppingItem", b =>
|
||||
{
|
||||
b.HasOne("FamilyPlanner.Domain.Shopping.ShoppingList", null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Calendar;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Domain.Settings;
|
||||
using FamilyPlanner.Domain.Shopping;
|
||||
using FamilyPlanner.Infrastructure.Identity;
|
||||
@@ -41,9 +42,21 @@ public sealed partial class DatabaseSeeder(
|
||||
await SeedSettingsAsync(cancellationToken);
|
||||
await SeedShoppingListsAsync(cancellationToken);
|
||||
await SeedCalendarsAsync(cancellationToken);
|
||||
await SeedChatAsync(cancellationToken);
|
||||
await SeedAdminAsync();
|
||||
}
|
||||
|
||||
private async Task SeedChatAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await db.ChatChannels.AnyAsync(c => c.Kind == ChatChannelKind.Family, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.ChatChannels.Add(new ChatChannel { Kind = ChatChannelKind.Family });
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedCalendarsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await db.Calendars.IgnoreQueryFilters().AnyAsync(cancellationToken))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FluentValidation;
|
||||
|
||||
namespace FamilyPlanner.Shared.Chat;
|
||||
|
||||
/// <summary>Letzte Nachricht eines Kanals für die Liste („Foto“ bei reinen Bildern).</summary>
|
||||
public sealed record ChatPreviewDto(Guid SenderId, string? Text, bool HasImage, DateTimeOffset At);
|
||||
|
||||
/// <summary>
|
||||
/// Ein Kanal aus Sicht des angemeldeten Benutzers. Bei Direktnachrichten ist <see cref="PartnerId"/> die andere Person und
|
||||
/// <see cref="Title"/> ihr Name; <see cref="CanSend"/> ist <c>false</c>, wenn die Person nicht mehr erreichbar ist.
|
||||
/// </summary>
|
||||
public sealed record ChatChannelDto(
|
||||
Guid Id,
|
||||
ChatChannelKind Kind,
|
||||
string Title,
|
||||
Guid? PartnerId,
|
||||
bool CanSend,
|
||||
int UnreadCount,
|
||||
DateTimeOffset? LastMessageAt,
|
||||
ChatPreviewDto? LastMessage);
|
||||
|
||||
public sealed record ChatChannelListDto(IReadOnlyList<ChatChannelDto> Channels, int TotalUnread);
|
||||
|
||||
public sealed record ChatReactionDto(string Emoji, IReadOnlyList<Guid> UserIds);
|
||||
|
||||
/// <summary>Nachricht. Das Bild wird über <see cref="ImageUrl"/> geladen (nie als Nutzdaten verschickt).</summary>
|
||||
public sealed record ChatMessageDto(
|
||||
Guid Id,
|
||||
Guid ChannelId,
|
||||
Guid SenderId,
|
||||
string SenderName,
|
||||
string? Text,
|
||||
string? ImageUrl,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<ChatReactionDto> Reactions);
|
||||
|
||||
/// <summary>Gelesen-Stand einer Person (für „Gelesen“-Haken).</summary>
|
||||
public sealed record ChatReadStateDto(Guid UserId, DateTimeOffset LastReadAt);
|
||||
|
||||
/// <summary>Eine Seite des Verlaufs in zeitlicher Reihenfolge (älteste zuerst); <see cref="HasMore"/>: es gibt noch ältere Nachrichten.</summary>
|
||||
public sealed record ChatMessagePageDto(IReadOnlyList<ChatMessageDto> Messages, bool HasMore, IReadOnlyList<ChatReadStateDto> ReadStates);
|
||||
|
||||
/// <summary>Neue Nachricht; die Id erzeugt der Client – wiederholtes Senden ist unschädlich.</summary>
|
||||
public sealed record SendChatMessageRequest(Guid Id, string Text);
|
||||
|
||||
public sealed record StartDirectChatRequest(Guid UserId);
|
||||
|
||||
/// <summary>Als gelesen markieren bis zu dieser Nachricht (ohne Angabe: bis zur neuesten).</summary>
|
||||
public sealed record MarkChatReadRequest(Guid? UpToMessageId = null);
|
||||
|
||||
/// <summary>Reaktion setzen (<see cref="Active"/> = <c>true</c>) oder entfernen – zustandsbasiert und damit idempotent.</summary>
|
||||
public sealed record SetChatReactionRequest(string Emoji, bool Active);
|
||||
|
||||
public sealed record ChatReactionsChangedDto(Guid ChannelId, Guid MessageId, IReadOnlyList<ChatReactionDto> Reactions);
|
||||
|
||||
/// <summary>Namen der SignalR-Ereignisse am <c>/hubs/live</c>-Hub.</summary>
|
||||
public static class ChatEvents
|
||||
{
|
||||
public const string MessageReceived = "ChatMessageReceived";
|
||||
public const string MessageRemoved = "ChatMessageRemoved";
|
||||
public const string ReactionsChanged = "ChatReactionsChanged";
|
||||
public const string ReadChanged = "ChatReadChanged";
|
||||
public const string Typing = "ChatTyping";
|
||||
|
||||
/// <summary>Hub-Methode: „ich tippe“ (Kanal-Id).</summary>
|
||||
public const string TypingMethod = "Typing";
|
||||
}
|
||||
|
||||
public sealed class SendChatMessageRequestValidator : AbstractValidator<SendChatMessageRequest>
|
||||
{
|
||||
public SendChatMessageRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Id).NotEmpty().WithMessage("Die Nachrichten-Id fehlt.");
|
||||
RuleFor(r => ChatRules.NormalizeText(r.Text)).NotEmpty().WithMessage("Bitte eine Nachricht eingeben.")
|
||||
.OverridePropertyName(nameof(SendChatMessageRequest.Text));
|
||||
RuleFor(r => ChatRules.NormalizeText(r.Text)!.Length).LessThanOrEqualTo(ChatMessage.TextMaxLength)
|
||||
.When(r => ChatRules.NormalizeText(r.Text) is not null)
|
||||
.WithMessage($"Die Nachricht darf höchstens {ChatMessage.TextMaxLength} Zeichen haben.")
|
||||
.OverridePropertyName(nameof(SendChatMessageRequest.Text));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StartDirectChatRequestValidator : AbstractValidator<StartDirectChatRequest>
|
||||
{
|
||||
public StartDirectChatRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.UserId).NotEmpty().WithMessage("Bitte eine Person auswählen.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetChatReactionRequestValidator : AbstractValidator<SetChatReactionRequest>
|
||||
{
|
||||
public SetChatReactionRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Emoji).Must(ChatRules.IsAllowedReaction).WithMessage("Diese Reaktion ist nicht erlaubt.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user