diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs b/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs
index 729ad00..8720959 100644
--- a/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs
+++ b/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs
@@ -17,7 +17,10 @@ public sealed record SendMessageCommand(
List? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
- Guid? ForwardedFromId = null) : ICommand;
+ Guid? ForwardedFromId = null,
+ Guid? StoryId = null,
+ string? StoryMediaUrl = null,
+ string? StoryMediaType = null) : ICommand;
public sealed class SendMessageCommandHandler : ICommandHandler
{
@@ -55,7 +58,10 @@ public sealed class SendMessageCommandHandler : ICommandHandler messageIds, CancellationToken cancellationToken);
Task AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
+ Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
}
diff --git a/apps/server-net/src/Modules/Chats/Domain/Message.cs b/apps/server-net/src/Modules/Chats/Domain/Message.cs
index d47ee1d..0efefd7 100644
--- a/apps/server-net/src/Modules/Chats/Domain/Message.cs
+++ b/apps/server-net/src/Modules/Chats/Domain/Message.cs
@@ -21,6 +21,9 @@ public sealed class Message : AggregateRoot
public bool IsEdited { get; private set; }
public bool IsDeleted { get; private set; }
public Guid? ForwardedFromId { get; private set; }
+ public Guid? StoryId { get; private set; }
+ public string? StoryMediaUrl { get; private set; }
+ public string? StoryMediaType { get; private set; }
public DateTime CreatedAt { get; private set; }
private readonly List _media = new();
@@ -32,7 +35,7 @@ public sealed class Message : AggregateRoot
private readonly List _deletedByUsers = new();
public IReadOnlyCollection DeletedByUsers => _deletedByUsers.AsReadOnly();
- private Message(Guid id, Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null) : base(id)
+ private Message(Guid id, Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null, Guid? storyId = null, string? storyMediaUrl = null, string? storyMediaType = null) : base(id)
{
ChatId = chatId;
SenderId = senderId;
@@ -41,14 +44,17 @@ public sealed class Message : AggregateRoot
ReplyToId = replyToId;
Quote = quote;
ForwardedFromId = forwardedFromId;
+ StoryId = storyId;
+ StoryMediaUrl = storyMediaUrl;
+ StoryMediaType = storyMediaType;
CreatedAt = DateTime.UtcNow;
RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content));
}
- public static Message Create(Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null)
+ public static Message Create(Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null, Guid? storyId = null, string? storyMediaUrl = null, string? storyMediaType = null)
{
- return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId);
+ return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType);
}
public void AddMedia(string type, string url, string? filename, long? size)
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs
index 7aede53..e945149 100644
--- a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs
@@ -111,4 +111,12 @@ public sealed class MessageRepository : IMessageRepository
return true;
}
+
+ public async Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
+ {
+ return await _dbContext.Messages
+ .Where(m => m.ChatId == chatId && m.StoryId == storyId)
+ .OrderByDescending(m => m.CreatedAt)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
}
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.Designer.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.Designer.cs
new file mode 100644
index 0000000..64db092
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.Designer.cs
@@ -0,0 +1,254 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Vortex.Modules.Chats.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(ChatsDbContext))]
+ [Migration("20260313183634_AddStoryIdToMessages")]
+ partial class AddStoryIdToMessages
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("chats")
+ .HasAnnotation("ProductVersion", "10.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Avatar")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Chats", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatId")
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.PrimitiveCollection("DeletedByUsers")
+ .IsRequired()
+ .HasColumnType("uuid[]")
+ .HasColumnName("DeletedByUsers");
+
+ b.Property("ForwardedFromId")
+ .HasColumnType("uuid");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsEdited")
+ .HasColumnType("boolean");
+
+ b.Property("Quote")
+ .HasColumnType("text");
+
+ b.Property("ReplyToId")
+ .HasColumnType("uuid");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Messages", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Emoji")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MessageId", "UserId", "Emoji")
+ .IsUnique();
+
+ b.ToTable("MessageReactions", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReadAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MessageId", "UserId")
+ .IsUnique();
+
+ b.ToTable("ReadReceipts", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
+ {
+ b.OwnsMany("Vortex.Modules.Chats.Domain.ChatMember", "Members", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b1.Property("ChatId")
+ .HasColumnType("uuid");
+
+ b1.Property("IsMuted")
+ .HasColumnType("boolean");
+
+ b1.Property("IsPinned")
+ .HasColumnType("boolean");
+
+ b1.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b1.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.Property("UserId")
+ .HasColumnType("uuid");
+
+ b1.HasKey("Id");
+
+ b1.HasIndex("ChatId", "UserId")
+ .IsUnique();
+
+ b1.ToTable("ChatMembers", "chats");
+
+ b1.WithOwner()
+ .HasForeignKey("ChatId");
+ });
+
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
+ {
+ b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
+ {
+ b1.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b1.Property("Filename")
+ .HasColumnType("text");
+
+ b1.Property("Size")
+ .HasColumnType("bigint");
+
+ b1.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.HasKey("MessageId", "Id");
+
+ b1.ToTable("MessageMedia", "chats");
+
+ b1.WithOwner()
+ .HasForeignKey("MessageId");
+ });
+
+ b.Navigation("Media");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
+ {
+ b.HasOne("Vortex.Modules.Chats.Domain.Message", null)
+ .WithMany("Reactions")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
+ {
+ b.HasOne("Vortex.Modules.Chats.Domain.Message", null)
+ .WithMany("ReadBy")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
+ {
+ b.Navigation("Reactions");
+
+ b.Navigation("ReadBy");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.cs
new file mode 100644
index 0000000..930d56c
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.cs
@@ -0,0 +1,31 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddStoryIdToMessages : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "StoryId",
+ schema: "chats",
+ table: "Messages",
+ type: "uuid",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "StoryId",
+ schema: "chats",
+ table: "Messages");
+ }
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.Designer.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.Designer.cs
new file mode 100644
index 0000000..2167c10
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.Designer.cs
@@ -0,0 +1,260 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Vortex.Modules.Chats.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(ChatsDbContext))]
+ [Migration("20260313201532_AddStoryMediaInfoToMessages")]
+ partial class AddStoryMediaInfoToMessages
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("chats")
+ .HasAnnotation("ProductVersion", "10.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Avatar")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Chats", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatId")
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.PrimitiveCollection("DeletedByUsers")
+ .IsRequired()
+ .HasColumnType("uuid[]")
+ .HasColumnName("DeletedByUsers");
+
+ b.Property("ForwardedFromId")
+ .HasColumnType("uuid");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsEdited")
+ .HasColumnType("boolean");
+
+ b.Property("Quote")
+ .HasColumnType("text");
+
+ b.Property("ReplyToId")
+ .HasColumnType("uuid");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("StoryMediaType")
+ .HasColumnType("text");
+
+ b.Property("StoryMediaUrl")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Messages", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Emoji")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MessageId", "UserId", "Emoji")
+ .IsUnique();
+
+ b.ToTable("MessageReactions", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("ReadAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MessageId", "UserId")
+ .IsUnique();
+
+ b.ToTable("ReadReceipts", "chats");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
+ {
+ b.OwnsMany("Vortex.Modules.Chats.Domain.ChatMember", "Members", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b1.Property("ChatId")
+ .HasColumnType("uuid");
+
+ b1.Property("IsMuted")
+ .HasColumnType("boolean");
+
+ b1.Property("IsPinned")
+ .HasColumnType("boolean");
+
+ b1.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b1.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.Property("UserId")
+ .HasColumnType("uuid");
+
+ b1.HasKey("Id");
+
+ b1.HasIndex("ChatId", "UserId")
+ .IsUnique();
+
+ b1.ToTable("ChatMembers", "chats");
+
+ b1.WithOwner()
+ .HasForeignKey("ChatId");
+ });
+
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
+ {
+ b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
+ {
+ b1.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b1.Property("Filename")
+ .HasColumnType("text");
+
+ b1.Property("Size")
+ .HasColumnType("bigint");
+
+ b1.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b1.HasKey("MessageId", "Id");
+
+ b1.ToTable("MessageMedia", "chats");
+
+ b1.WithOwner()
+ .HasForeignKey("MessageId");
+ });
+
+ b.Navigation("Media");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
+ {
+ b.HasOne("Vortex.Modules.Chats.Domain.Message", null)
+ .WithMany("Reactions")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
+ {
+ b.HasOne("Vortex.Modules.Chats.Domain.Message", null)
+ .WithMany("ReadBy")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
+ {
+ b.Navigation("Reactions");
+
+ b.Navigation("ReadBy");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.cs
new file mode 100644
index 0000000..8eada32
--- /dev/null
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.cs
@@ -0,0 +1,42 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddStoryMediaInfoToMessages : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "StoryMediaType",
+ schema: "chats",
+ table: "Messages",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "StoryMediaUrl",
+ schema: "chats",
+ table: "Messages",
+ type: "text",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "StoryMediaType",
+ schema: "chats",
+ table: "Messages");
+
+ migrationBuilder.DropColumn(
+ name: "StoryMediaUrl",
+ schema: "chats",
+ table: "Messages");
+ }
+ }
+}
diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs
index 78e469f..ec6064e 100644
--- a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs
+++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs
@@ -85,6 +85,15 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
b.Property("SenderId")
.HasColumnType("uuid");
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("StoryMediaType")
+ .HasColumnType("text");
+
+ b.Property("StoryMediaUrl")
+ .HasColumnType("text");
+
b.Property("Type")
.IsRequired()
.HasColumnType("text");
diff --git a/apps/server-net/src/Modules/Identity/Domain/Story.cs b/apps/server-net/src/Modules/Identity/Domain/Story.cs
index 8222048..1e9dc63 100644
--- a/apps/server-net/src/Modules/Identity/Domain/Story.cs
+++ b/apps/server-net/src/Modules/Identity/Domain/Story.cs
@@ -2,7 +2,7 @@ using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
-public sealed class Story : Entity
+public class Story : Entity
{
public Guid UserId { get; private set; }
public string Type { get; private set; } // text, image, video
@@ -13,9 +13,16 @@ public sealed class Story : Entity
public DateTime ExpiresAt { get; private set; }
private readonly List _viewers = new();
- public IReadOnlyCollection Viewers => _viewers.AsReadOnly();
+ private readonly List _reactions = new();
+ private readonly List _replies = new();
- private Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id)
+ public IReadOnlyCollection Viewers => _viewers.AsReadOnly();
+ public IReadOnlyCollection Reactions => _reactions.AsReadOnly();
+ public IReadOnlyCollection Replies => _replies.AsReadOnly();
+
+ protected Story() : base(Guid.NewGuid()) { }
+
+ internal Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id)
{
UserId = userId;
Type = type;
@@ -36,4 +43,21 @@ public sealed class Story : Entity
if (_viewers.Any(v => v.UserId == userId)) return;
_viewers.Add(new StoryViewer(Id, userId));
}
+
+ public void AddReaction(Guid userId, string emoji)
+ {
+ if (_reactions.Any(r => r.UserId == userId && r.Emoji == emoji)) return;
+ _reactions.Add(new StoryReaction(Id, userId, emoji));
+ }
+
+ public void RemoveReaction(Guid userId, string emoji)
+ {
+ var reaction = _reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
+ if (reaction != null) _reactions.Remove(reaction);
+ }
+
+ public void AddReply(Guid userId, string content)
+ {
+ _replies.Add(new StoryReply(Id, userId, content));
+ }
}
diff --git a/apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs b/apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs
new file mode 100644
index 0000000..43353b6
--- /dev/null
+++ b/apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs
@@ -0,0 +1,21 @@
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Identity.Domain;
+
+public sealed class StoryReaction : Entity
+{
+ public Guid StoryId { get; set; }
+ public Guid UserId { get; set; }
+ public string Emoji { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+
+ private StoryReaction() : base(Guid.NewGuid()) { }
+
+ public StoryReaction(Guid storyId, Guid userId, string emoji) : base(Guid.NewGuid())
+ {
+ StoryId = storyId;
+ UserId = userId;
+ Emoji = emoji;
+ CreatedAt = DateTime.UtcNow;
+ }
+}
diff --git a/apps/server-net/src/Modules/Identity/Domain/StoryReply.cs b/apps/server-net/src/Modules/Identity/Domain/StoryReply.cs
new file mode 100644
index 0000000..eeed099
--- /dev/null
+++ b/apps/server-net/src/Modules/Identity/Domain/StoryReply.cs
@@ -0,0 +1,21 @@
+using Vortex.Shared.Kernel;
+
+namespace Vortex.Modules.Identity.Domain;
+
+public sealed class StoryReply : Entity
+{
+ public Guid StoryId { get; set; }
+ public Guid UserId { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+
+ private StoryReply() : base(Guid.NewGuid()) { }
+
+ public StoryReply(Guid storyId, Guid userId, string content) : base(Guid.NewGuid())
+ {
+ StoryId = storyId;
+ UserId = userId;
+ Content = content;
+ CreatedAt = DateTime.UtcNow;
+ }
+}
diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs
index eb69b24..9a7b794 100644
--- a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs
+++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs
@@ -1,10 +1,10 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
-using Vortex.Shared.Kernel;
-using Vortex.Modules.Identity.Domain;
-
+using Microsoft.EntityFrameworkCore.Metadata;
using Vortex.Modules.Identity.Application.Abstractions;
+using Vortex.Modules.Identity.Domain;
+using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Infrastructure.Persistence;
@@ -24,8 +24,11 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
public DbSet Users => Set();
public DbSet Stories => Set();
public DbSet StoryViewers => Set();
+ public DbSet StoryReactions => Set();
+ public DbSet StoryReplies => Set();
public DbSet Friendships => Set();
-
+
+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
@@ -48,10 +51,26 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
builder.ToTable("Stories");
builder.HasKey(s => s.Id);
builder.Property(s => s.Type).IsRequired();
+
+ // Configure backing fields for collections
+
+ builder.Metadata.FindNavigation(nameof(Story.Viewers))?.SetPropertyAccessMode(PropertyAccessMode.Field);
+ builder.Metadata.FindNavigation(nameof(Story.Reactions))?.SetPropertyAccessMode(PropertyAccessMode.Field);
+ builder.Metadata.FindNavigation(nameof(Story.Replies))?.SetPropertyAccessMode(PropertyAccessMode.Field);
+
+
builder.HasMany(s => s.Viewers)
.WithOne()
.HasForeignKey(v => v.StoryId)
.OnDelete(DeleteBehavior.Cascade);
+ builder.HasMany(s => s.Reactions)
+ .WithOne()
+ .HasForeignKey(r => r.StoryId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasMany(s => s.Replies)
+ .WithOne()
+ .HasForeignKey(r => r.StoryId)
+ .OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(builder =>
@@ -61,6 +80,21 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
builder.HasIndex(v => new { v.StoryId, v.UserId }).IsUnique();
});
+ modelBuilder.Entity(builder =>
+ {
+ builder.ToTable("StoryReactions");
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Emoji).IsRequired().HasMaxLength(10);
+ builder.HasIndex(r => new { r.StoryId, r.UserId, r.Emoji }).IsUnique();
+ });
+
+ modelBuilder.Entity(builder =>
+ {
+ builder.ToTable("StoryReplies");
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Content).IsRequired().HasMaxLength(500);
+ });
+
modelBuilder.Entity(builder =>
{
builder.ToTable("Users");
@@ -82,7 +116,8 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
{
var domainEvents = ChangeTracker
.Entries()
- .SelectMany(x =>
+ .SelectMany(x =>
+
{
if (x.Entity is AggregateRoot root)
{
diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.Designer.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.Designer.cs
new file mode 100644
index 0000000..c2ee07b
--- /dev/null
+++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.Designer.cs
@@ -0,0 +1,255 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Vortex.Modules.Identity.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(IdentityDbContext))]
+ [Migration("20260313115319_AddStoryReactionsReplies")]
+ partial class AddStoryReactionsReplies
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("identity")
+ .HasAnnotation("ProductVersion", "10.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.Friendship", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FriendId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "FriendId")
+ .IsUnique();
+
+ b.ToTable("Friendships", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.Story", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BgColor")
+ .HasColumnType("text");
+
+ b.Property("Content")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MediaUrl")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("Stories", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Emoji")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoryId", "UserId", "Emoji")
+ .IsUnique();
+
+ b.ToTable("StoryReactions", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoryId");
+
+ b.ToTable("StoryReplies", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("ViewedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoryId", "UserId")
+ .IsUnique();
+
+ b.ToTable("StoryViewers", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Avatar")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Bio")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Birthday")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Email")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("HideStoryViews")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b =>
+ {
+ b.HasOne("Vortex.Modules.Identity.Domain.Story", null)
+ .WithMany("Reactions")
+ .HasForeignKey("StoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b =>
+ {
+ b.HasOne("Vortex.Modules.Identity.Domain.Story", null)
+ .WithMany("Replies")
+ .HasForeignKey("StoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
+ {
+ b.HasOne("Vortex.Modules.Identity.Domain.Story", null)
+ .WithMany("Viewers")
+ .HasForeignKey("StoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.Story", b =>
+ {
+ b.Navigation("Reactions");
+
+ b.Navigation("Replies");
+
+ b.Navigation("Viewers");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.cs
new file mode 100644
index 0000000..40f8ff0
--- /dev/null
+++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.cs
@@ -0,0 +1,86 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddStoryReactionsReplies : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "StoryReactions",
+ schema: "identity",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ StoryId = table.Column(type: "uuid", nullable: false),
+ UserId = table.Column(type: "uuid", nullable: false),
+ Emoji = table.Column(type: "character varying(10)", maxLength: 10, nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_StoryReactions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_StoryReactions_Stories_StoryId",
+ column: x => x.StoryId,
+ principalSchema: "identity",
+ principalTable: "Stories",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "StoryReplies",
+ schema: "identity",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ StoryId = table.Column(type: "uuid", nullable: false),
+ UserId = table.Column(type: "uuid", nullable: false),
+ Content = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_StoryReplies", x => x.Id);
+ table.ForeignKey(
+ name: "FK_StoryReplies_Stories_StoryId",
+ column: x => x.StoryId,
+ principalSchema: "identity",
+ principalTable: "Stories",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_StoryReactions_StoryId_UserId_Emoji",
+ schema: "identity",
+ table: "StoryReactions",
+ columns: new[] { "StoryId", "UserId", "Emoji" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_StoryReplies_StoryId",
+ schema: "identity",
+ table: "StoryReplies",
+ column: "StoryId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "StoryReactions",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "StoryReplies",
+ schema: "identity");
+ }
+ }
+}
diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
index 412c5ba..c5ac68f 100644
--- a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
+++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
@@ -82,6 +82,61 @@ namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
b.ToTable("Stories", "identity");
});
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Emoji")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoryId", "UserId", "Emoji")
+ .IsUnique();
+
+ b.ToTable("StoryReactions", "identity");
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StoryId")
+ .HasColumnType("uuid");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoryId");
+
+ b.ToTable("StoryReplies", "identity");
+ });
+
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property("Id")
@@ -156,6 +211,24 @@ namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
b.ToTable("Users", "identity");
});
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b =>
+ {
+ b.HasOne("Vortex.Modules.Identity.Domain.Story", null)
+ .WithMany("Reactions")
+ .HasForeignKey("StoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b =>
+ {
+ b.HasOne("Vortex.Modules.Identity.Domain.Story", null)
+ .WithMany("Replies")
+ .HasForeignKey("StoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
{
b.HasOne("Vortex.Modules.Identity.Domain.Story", null)
@@ -167,6 +240,10 @@ namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Vortex.Modules.Identity.Domain.Story", b =>
{
+ b.Navigation("Reactions");
+
+ b.Navigation("Replies");
+
b.Navigation("Viewers");
});
#pragma warning restore 612, 618
diff --git a/apps/server-net/src/Modules/Identity/migrations.sql b/apps/server-net/src/Modules/Identity/migrations.sql
new file mode 100644
index 0000000..457553b
--- /dev/null
+++ b/apps/server-net/src/Modules/Identity/migrations.sql
@@ -0,0 +1,229 @@
+CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
+ "MigrationId" character varying(150) NOT NULL,
+ "ProductVersion" character varying(32) NOT NULL,
+ CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
+);
+
+START TRANSACTION;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN
+ IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'identity') THEN
+ CREATE SCHEMA identity;
+ END IF;
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN
+ CREATE TABLE identity."Users" (
+ "Id" uuid NOT NULL,
+ "Username" character varying(50) NOT NULL,
+ "PasswordHash" text NOT NULL,
+ "DisplayName" character varying(100) NOT NULL,
+ "Email" character varying(255),
+ CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
+ );
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN
+ CREATE UNIQUE INDEX "IX_Users_Username" ON identity."Users" ("Username");
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN
+ INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
+ VALUES ('20260311180816_InitialIdentity', '10.0.4');
+ END IF;
+END $EF$;
+COMMIT;
+
+START TRANSACTION;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ ALTER TABLE identity."Users" ADD "Avatar" character varying(500);
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ ALTER TABLE identity."Users" ADD "Bio" character varying(500);
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ ALTER TABLE identity."Users" ADD "Birthday" timestamp with time zone;
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ ALTER TABLE identity."Users" ADD "CreatedAt" timestamp with time zone NOT NULL DEFAULT TIMESTAMPTZ '-infinity';
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ CREATE TABLE identity."Friendships" (
+ "Id" uuid NOT NULL,
+ "UserId" uuid NOT NULL,
+ "FriendId" uuid NOT NULL,
+ "Status" integer NOT NULL,
+ "CreatedAt" timestamp with time zone NOT NULL,
+ CONSTRAINT "PK_Friendships" PRIMARY KEY ("Id")
+ );
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ CREATE TABLE identity."Stories" (
+ "Id" uuid NOT NULL,
+ "UserId" uuid NOT NULL,
+ "Type" text NOT NULL,
+ "MediaUrl" text,
+ "Content" text,
+ "BgColor" text,
+ "CreatedAt" timestamp with time zone NOT NULL,
+ "ExpiresAt" timestamp with time zone NOT NULL,
+ CONSTRAINT "PK_Stories" PRIMARY KEY ("Id")
+ );
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ CREATE UNIQUE INDEX "IX_Friendships_UserId_FriendId" ON identity."Friendships" ("UserId", "FriendId");
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN
+ INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
+ VALUES ('20260311200839_UpdateIdentityWithNewFieldsAndTables', '10.0.4');
+ END IF;
+END $EF$;
+COMMIT;
+
+START TRANSACTION;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311215347_AddHideStoryViewsToUser') THEN
+ ALTER TABLE identity."Users" ADD "HideStoryViews" boolean NOT NULL DEFAULT FALSE;
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311215347_AddHideStoryViewsToUser') THEN
+ INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
+ VALUES ('20260311215347_AddHideStoryViewsToUser', '10.0.4');
+ END IF;
+END $EF$;
+COMMIT;
+
+START TRANSACTION;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN
+ CREATE TABLE identity."StoryViewers" (
+ "Id" uuid NOT NULL,
+ "StoryId" uuid NOT NULL,
+ "UserId" uuid NOT NULL,
+ "ViewedAt" timestamp with time zone NOT NULL,
+ CONSTRAINT "PK_StoryViewers" PRIMARY KEY ("Id"),
+ CONSTRAINT "FK_StoryViewers_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE
+ );
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN
+ CREATE UNIQUE INDEX "IX_StoryViewers_StoryId_UserId" ON identity."StoryViewers" ("StoryId", "UserId");
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN
+ INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
+ VALUES ('20260312174453_AddHideStoryViewsAndFixStoryViewer', '10.0.4');
+ END IF;
+END $EF$;
+COMMIT;
+
+START TRANSACTION;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN
+ CREATE TABLE identity."StoryReactions" (
+ "Id" uuid NOT NULL,
+ "StoryId" uuid NOT NULL,
+ "UserId" uuid NOT NULL,
+ "Emoji" character varying(10) NOT NULL,
+ "CreatedAt" timestamp with time zone NOT NULL,
+ CONSTRAINT "PK_StoryReactions" PRIMARY KEY ("Id"),
+ CONSTRAINT "FK_StoryReactions_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE
+ );
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN
+ CREATE TABLE identity."StoryReplies" (
+ "Id" uuid NOT NULL,
+ "StoryId" uuid NOT NULL,
+ "UserId" uuid NOT NULL,
+ "Content" character varying(500) NOT NULL,
+ "CreatedAt" timestamp with time zone NOT NULL,
+ CONSTRAINT "PK_StoryReplies" PRIMARY KEY ("Id"),
+ CONSTRAINT "FK_StoryReplies_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE
+ );
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN
+ CREATE UNIQUE INDEX "IX_StoryReactions_StoryId_UserId_Emoji" ON identity."StoryReactions" ("StoryId", "UserId", "Emoji");
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN
+ CREATE INDEX "IX_StoryReplies_StoryId" ON identity."StoryReplies" ("StoryId");
+ END IF;
+END $EF$;
+
+DO $EF$
+BEGIN
+ IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN
+ INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
+ VALUES ('20260313115319_AddStoryReactionsReplies', '10.0.4');
+ END IF;
+END $EF$;
+COMMIT;
+
diff --git a/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs b/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs
index 8af1dc1..92c4757 100644
--- a/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs
+++ b/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs
@@ -6,6 +6,8 @@ using Vortex.Shared.Kernel;
using Vortex.Modules.Chats.Application.Messages.Send;
using Vortex.Modules.Identity.Domain;
using Vortex.Modules.Identity.Application.Abstractions;
+using System.Linq;
+using System.Text.RegularExpressions;
namespace Vortex.Host.Controllers;
@@ -58,6 +60,7 @@ public sealed class MessagesController : ControllerBase
{
id = replyMsg.Id,
content = replyMsg.Content,
+ isDeleted = replyMsg.IsDeleted,
media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(),
sender = replySender != null ? new { id = replySender.Id, username = replySender.Username, displayName = replySender.DisplayName } : null
};
@@ -99,6 +102,7 @@ public sealed class MessagesController : ControllerBase
displayName = fwd.DisplayName,
avatar = fwd.Avatar
} : null,
+ storyId = m.StoryId,
media = m.Media.Select(media => new {
media.Id,
media.Type,
@@ -196,6 +200,76 @@ public sealed class MessagesController : ControllerBase
return Ok(new { url = $"/uploads/{fileName}", filename = file.FileName, size = file.Length });
}
+ [HttpGet("chat/{chatId:guid}/shared")]
+ public async Task GetSharedMedia(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? type, CancellationToken ct)
+ {
+ var messages = await messageRepository.GetChatMessagesAsync(chatId, 300, 0, ct);
+
+ // Filter out deleted messages
+ messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(_userContext.UserId)).ToList();
+
+ var result = new List
- {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
+ {message.replyTo.isDeleted ? (
+
{t('messageDeleted')}
+ ) : (
+ <>
+ {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
+
+ {message.replyTo.media[0].type === 'image' ? (
+

+ ) : message.replyTo.media[0].type === 'video' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}
+ >
+ )}
+
+
+ )}
+
+ {/* Story Reply Quote */}
+ {message.storyId && (
+
+
+ {t('story')}
+
+
+ {message.storyMediaUrl && (
- {message.replyTo.media[0].type === 'image' ? (
-

- ) : message.replyTo.media[0].type === 'video' ? (
-
+ {message.storyMediaType === 'video' ? (
+
+ ) : message.storyMediaType === 'image' ? (
+
)
) : (
-
+
)}
)}
-
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}
+
+ {message.quote}
+
)}
@@ -442,10 +458,10 @@ function MessageBubble({
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${hasImage && !message.content
- ? 'p-0 shadow-none border-none'
- : isMine
- ? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
- : 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
+ ? 'p-0 shadow-none border-none'
+ : isMine
+ ? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
+ : 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
}`}
>
{/* Рендер пересланного сообщения */}
@@ -467,10 +483,10 @@ function MessageBubble({
{(hasImage || hasVideo) && (
m.type === 'image' || m.type === 'video').length >= 3
- ? 'grid-cols-3'
- : media.filter(m => m.type === 'image' || m.type === 'video').length === 2
- ? 'grid-cols-2'
- : 'grid-cols-1'
+ ? 'grid-cols-3'
+ : media.filter(m => m.type === 'image' || m.type === 'video').length === 2
+ ? 'grid-cols-2'
+ : 'grid-cols-1'
}`}>
{media
.filter((m) => m.type === 'image' || m.type === 'video')
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 717eaf3..6d9470b 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -22,6 +22,7 @@ import NewChatModal from './NewChatModal';
import UserProfile from './UserProfile';
import SideMenu from './SideMenu';
import StoryViewer, { CreateStoryModal } from './StoryViewer';
+import { useStoryStore } from '../stores/useStoryStore';
const API_URL = import.meta.env.VITE_API_URL || '';
@@ -32,8 +33,7 @@ export default function Sidebar() {
const [showNewChat, setShowNewChat] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [showSideMenu, setShowSideMenu] = useState(false);
- const [storyGroups, setStoryGroups] = useState
([]);
- const [storyViewerIndex, setStoryViewerIndex] = useState(null);
+ const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
const [showCreateStory, setShowCreateStory] = useState(false);
const loadStories = () => {
@@ -165,7 +165,7 @@ export default function Sidebar() {
return (