Ответы и реакции истории, публикации в профиле. Фиксы

This commit is contained in:
Халимов Рустам
2026-03-13 23:34:32 +03:00
parent ca1d88191c
commit 010b96d362
62 changed files with 2365 additions and 189 deletions
@@ -17,7 +17,10 @@ public sealed record SendMessageCommand(
List<AttachmentRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null) : ICommand<Guid>;
Guid? ForwardedFromId = null,
Guid? StoryId = null,
string? StoryMediaUrl = null,
string? StoryMediaType = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -55,7 +58,10 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
request.Type,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
request.ForwardedFromId,
request.StoryId,
request.StoryMediaUrl,
request.StoryMediaType);
if (request.Attachments != null && request.Attachments.Any())
{
@@ -11,4 +11,5 @@ public interface IMessageRepository
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
}
@@ -21,6 +21,9 @@ public sealed class Message : AggregateRoot<Guid>
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> _media = new();
@@ -32,7 +35,7 @@ public sealed class Message : AggregateRoot<Guid>
private readonly List<Guid> _deletedByUsers = new();
public IReadOnlyCollection<Guid> 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<Guid>
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)
@@ -111,4 +111,12 @@ public sealed class MessageRepository : IMessageRepository
return true;
}
public async Task<Message?> 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);
}
}
@@ -0,0 +1,254 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("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<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,31 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryIdToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "StoryId",
schema: "chats",
table: "Messages",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StoryId",
schema: "chats",
table: "Messages");
}
}
}
@@ -0,0 +1,260 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("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<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryMediaInfoToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StoryMediaType",
schema: "chats",
table: "Messages",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StoryMediaUrl",
schema: "chats",
table: "Messages",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StoryMediaType",
schema: "chats",
table: "Messages");
migrationBuilder.DropColumn(
name: "StoryMediaUrl",
schema: "chats",
table: "Messages");
}
}
}
@@ -85,6 +85,15 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
@@ -2,7 +2,7 @@ using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
public sealed class Story : Entity<Guid>
public class Story : Entity<Guid>
{
public Guid UserId { get; private set; }
public string Type { get; private set; } // text, image, video
@@ -13,9 +13,16 @@ public sealed class Story : Entity<Guid>
public DateTime ExpiresAt { get; private set; }
private readonly List<StoryViewer> _viewers = new();
public IReadOnlyCollection<StoryViewer> Viewers => _viewers.AsReadOnly();
private readonly List<StoryReaction> _reactions = new();
private readonly List<StoryReply> _replies = new();
private Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id)
public IReadOnlyCollection<StoryViewer> Viewers => _viewers.AsReadOnly();
public IReadOnlyCollection<StoryReaction> Reactions => _reactions.AsReadOnly();
public IReadOnlyCollection<StoryReply> 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<Guid>
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));
}
}
@@ -0,0 +1,21 @@
using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
public sealed class StoryReaction : Entity<Guid>
{
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;
}
}
@@ -0,0 +1,21 @@
using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
public sealed class StoryReply : Entity<Guid>
{
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;
}
}
@@ -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<User> Users => Set<User>();
public DbSet<Story> Stories => Set<Story>();
public DbSet<StoryViewer> StoryViewers => Set<StoryViewer>();
public DbSet<StoryReaction> StoryReactions => Set<StoryReaction>();
public DbSet<StoryReply> StoryReplies => Set<StoryReply>();
public DbSet<Friendship> Friendships => Set<Friendship>();
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<StoryViewer>(builder =>
@@ -61,6 +80,21 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
builder.HasIndex(v => new { v.StoryId, v.UserId }).IsUnique();
});
modelBuilder.Entity<StoryReaction>(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<StoryReply>(builder =>
{
builder.ToTable("StoryReplies");
builder.HasKey(r => r.Id);
builder.Property(r => r.Content).IsRequired().HasMaxLength(500);
});
modelBuilder.Entity<User>(builder =>
{
builder.ToTable("Users");
@@ -82,7 +116,8 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
{
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{
@@ -0,0 +1,255 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FriendId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BgColor")
.HasColumnType("text");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Stories", "identity");
});
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId");
b.ToTable("StoryReplies", "identity");
});
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("HideStoryViews")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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
}
}
}
@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryReactionsReplies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StoryReactions",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Emoji = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
CreatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
CreatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoryReactions",
schema: "identity");
migrationBuilder.DropTable(
name: "StoryReplies",
schema: "identity");
}
}
}
@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId");
b.ToTable("StoryReplies", "identity");
});
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("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
@@ -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;
@@ -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<IActionResult> 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<object>();
var filterType = type?.ToLower();
foreach (var m in messages)
{
if (filterType == "links")
{
var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase);
var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List<string>();
var mediaLinks = m.Media.Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList();
var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList();
if (allLinks.Any())
{
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
result.Add(new
{
m.Id,
m.Content,
m.CreatedAt,
links = allLinks,
sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null
});
}
continue;
}
if (m.Media == null || !m.Media.Any()) continue;
var filteredMedia = m.Media.Where(media => {
var mediaType = media.Type?.ToLower() ?? "file";
if (filterType == "media") return mediaType == "image" || mediaType == "video";
if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link";
return true;
}).ToList();
if (filteredMedia.Any())
{
var sender = await _userRepository.GetByIdAsync(m.SenderId, ct);
result.Add(new
{
m.Id,
m.ChatId,
m.SenderId,
m.Content,
m.Type,
m.CreatedAt,
media = filteredMedia.Select(media => new {
media.Id,
media.Type,
media.Url,
filename = media.Filename,
size = media.Size
}).ToList(),
sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null
});
}
}
return Ok(result.OrderByDescending(x => ((dynamic)x).CreatedAt).ToList());
}
[HttpPost("chat/{chatId:guid}")]
public async Task<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request)
{
@@ -6,6 +6,10 @@ using Vortex.Modules.Identity.Domain;
using Vortex.Modules.Identity.Infrastructure.Persistence;
using Vortex.Shared.Kernel;
using Vortex.Modules.Identity.Application.Abstractions;
using Microsoft.Extensions.Logging;
using MediatR;
using Vortex.Modules.Chats.Domain;
using Vortex.Modules.Chats.Application.Messages.Send;
namespace Vortex.Host.Controllers;
@@ -17,18 +21,30 @@ public sealed class StoriesController : ControllerBase
private readonly IdentityDbContext _context;
private readonly IUserContext _userContext;
private readonly IUserRepository _userRepository;
private readonly Microsoft.AspNetCore.SignalR.IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IChatRepository _chatRepository;
private readonly ISender _sender;
private readonly IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly ILogger<StoriesController> _logger;
public StoriesController(
IdentityDbContext context,
IUserContext userContext,
IUserRepository userRepository,
Microsoft.AspNetCore.SignalR.IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext)
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext,
ILogger<StoriesController> logger)
{
_context = context;
_userContext = userContext;
_userRepository = userRepository;
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_sender = sender;
_hubContext = hubContext;
_logger = logger;
}
[HttpGet]
@@ -45,6 +61,8 @@ public sealed class StoriesController : ControllerBase
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId))
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(ct);
@@ -83,7 +101,15 @@ public sealed class StoriesController : ControllerBase
createdAt = s.CreatedAt,
expiresAt = s.ExpiresAt,
viewCount = s.Viewers.Count,
viewed = s.Viewers.Any(v => v.UserId == currentUserId)
viewed = s.Viewers.Any(v => v.UserId == currentUserId),
reactions = s.Reactions.Select(r => new
{
id = r.Id,
userId = r.UserId,
emoji = r.Emoji,
createdAt = r.CreatedAt
}).ToList(),
replyCount = s.Replies.Count
}).OrderBy(s => s.createdAt).ToList(),
hasUnviewed = group.Any(s => !s.Viewers.Any(v => v.UserId == currentUserId))
});
@@ -115,26 +141,94 @@ public sealed class StoriesController : ControllerBase
return Ok(new { id = story.Id });
}
[HttpGet("user/{userId}")]
public async Task<IActionResult> GetUserStories(Guid userId, CancellationToken ct)
{
var currentUserId = _userContext.UserId;
var stories = await _context.Stories
.Include(s => s.Viewers)
.Include(s => s.Reactions)
.Include(s => s.Replies)
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(ct);
var user = await _userRepository.GetByIdAsync(userId, ct);
if (user == null) return NotFound();
var result = new
{
user = new
{
id = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar
},
stories = stories.Select(s => new
{
id = s.Id,
type = s.Type,
mediaUrl = s.MediaUrl,
content = s.Content,
bgColor = s.BgColor,
createdAt = s.CreatedAt,
expiresAt = s.ExpiresAt,
viewCount = s.Viewers.Count,
viewed = s.Viewers.Any(v => v.UserId == currentUserId),
reactions = s.Reactions.Select(r => new
{
id = r.Id,
userId = r.UserId,
emoji = r.Emoji,
createdAt = r.CreatedAt
}).ToList(),
replyCount = s.Replies.Count
}).ToList()
};
return Ok(result);
}
[HttpPost("{id}/view")]
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
{
_logger.LogInformation("ViewStory called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId);
try
{
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null) return NotFound();
if (story.UserId == _userContext.UserId) return Ok(new { message = "Owner view" });
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
if (story.UserId == _userContext.UserId)
{
_logger.LogInformation("Owner view, skipping");
return Ok(new { message = "Owner view" });
}
if (!story.Viewers.Any(v => v.UserId == _userContext.UserId))
{
story.AddViewer(_userContext.UserId);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Viewer added. Total viewers: {Count}", story.Viewers.Count);
// Notify owner via SignalR targetedly
// Notify owner via SignalR - send to all, filter on client
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.User(story.UserId.ToString()).SendAsync("story_viewed", new
_logger.LogInformation("Sending story_viewed to owner {OwnerId}", story.UserId);
// Get updated story with viewers to be sure count is accurate
var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, ct);
await _hubContext.Clients.All.SendAsync("story_viewed", new
{
storyId = story.Id,
userId = _userContext.UserId,
@@ -142,20 +236,27 @@ public sealed class StoriesController : ControllerBase
displayName = viewer?.DisplayName,
avatar = viewer?.Avatar,
viewedAt = DateTime.UtcNow,
viewCount = story.Viewers.Count,
viewCount = updatedStory.Viewers.Count,
ownerId = story.UserId
}, ct);
_logger.LogInformation("story_viewed sent successfully");
}
else
{
_logger.LogInformation("User already viewed this story");
}
return Ok(new { message = "Story viewed" });
}
catch (DbUpdateException)
catch (DbUpdateException ex)
{
// Likely a unique constraint violation (already viewed)
_logger.LogError(ex, "DbUpdateException in ViewStory");
return Ok(new { message = "Story already viewed" });
}
catch (Exception ex)
{
_logger.LogError(ex, "ViewStory error");
return StatusCode(500, ex.Message);
}
}
@@ -163,12 +264,25 @@ public sealed class StoriesController : ControllerBase
[HttpGet("{id}/viewers")]
public async Task<IActionResult> GetStoryViewers(Guid id, CancellationToken ct)
{
_logger.LogInformation("GetStoryViewers called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId);
var story = await _context.Stories
.Include(s => s.Viewers)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null) return NotFound();
if (story.UserId != _userContext.UserId) return Forbid();
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
if (story.UserId != _userContext.UserId)
{
_logger.LogWarning("Forbidden: User {UserId} is not owner of story {StoryId}", _userContext.UserId, id);
return Forbid();
}
_logger.LogInformation("Story has {Count} viewers", story.Viewers.Count);
var viewerIds = story.Viewers.Select(v => v.UserId).ToList();
var viewers = new List<object>();
@@ -190,9 +304,213 @@ public sealed class StoriesController : ControllerBase
});
}
_logger.LogInformation("Returning {Count} viewers", viewers.Count);
return Ok(viewers);
}
private string GetStoryQuote(Story story)
{
if (!string.IsNullOrEmpty(story.Content)) return story.Content;
return story.Type.ToLower() switch
{
"image" => "🖼 Фото",
"video" => "🎬 Видео",
_ => "История"
};
}
[HttpPost("{id}/reaction")]
public async Task<IActionResult> AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct)
{
_logger.LogInformation("AddReaction called: StoryId={StoryId}, UserId={UserId}, Emoji={Emoji}", id, _userContext.UserId, request.Emoji);
try
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
// Check if reaction already exists
var existing = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct);
if (existing != null)
{
_logger.LogInformation("Reaction already exists");
return Ok(new { message = "Reaction already exists" });
}
// Add reaction directly via DbSet
var reaction = new StoryReaction(id, _userContext.UserId, request.Emoji);
_context.StoryReactions.Add(reaction);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Reaction saved successfully");
// 1. Create/Find chat
var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct);
// 2. Threading: find last story message in this chat
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct);
if (lastStoryMessage != null)
{
// If message already exists for this story, add a reaction to it
var addReactionCommand = new Vortex.Modules.Chats.Application.Messages.React.AddReactionCommand(
lastStoryMessage.Id, _userContext.UserId, request.Emoji, chatId);
await _sender.Send(addReactionCommand, ct);
}
else
{
// Create new message for the story
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: _userContext.UserId,
Content: request.Emoji,
Type: "text",
Quote: storyQuote,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type);
await _sender.Send(messageCommand, ct);
}
// 3. Notify story owner in real-time (existing StoryViewer listeners)
var reactor = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.All.SendAsync("story_reaction", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = reactor?.Username,
displayName = reactor?.DisplayName,
avatar = reactor?.Avatar,
emoji = request.Emoji,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, ct);
return Ok(new { message = "Reaction added" });
}
catch (Exception ex)
{
_logger.LogError(ex, "AddReaction error");
return StatusCode(500, ex.Message);
}
}
[HttpDelete("{id}/reaction")]
public async Task<IActionResult> RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct)
{
var reaction = await _context.StoryReactions
.FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct);
if (reaction == null) return Ok(new { message = "Reaction not found" });
_context.StoryReactions.Remove(reaction);
await _context.SaveChangesAsync(ct);
return Ok(new { message = "Reaction removed" });
}
[HttpPost("{id}/reply")]
public async Task<IActionResult> AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct)
{
_logger.LogInformation("AddReply called: StoryId={StoryId}, UserId={UserId}, Content={Content}", id, _userContext.UserId, request.Content);
try
{
var story = await _context.Stories.FindAsync(new object[] { id }, ct);
if (story == null)
{
_logger.LogWarning("Story not found: {StoryId}", id);
return NotFound();
}
// Add reply directly via DbSet
var reply = new StoryReply(id, _userContext.UserId, request.Content);
_context.StoryReplies.Add(reply);
await _context.SaveChangesAsync(ct);
_logger.LogInformation("Reply saved successfully");
// 1. Create/Find chat
var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct);
// 2. Find last message for threading
var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct);
// 3. Send message to chat
var storyQuote = GetStoryQuote(story);
var messageCommand = new SendMessageCommand(
ChatId: chatId,
SenderId: _userContext.UserId,
Content: request.Content,
Type: "text",
Quote: storyQuote,
ReplyToId: lastStoryMessage?.Id,
StoryId: story.Id,
StoryMediaUrl: story.MediaUrl,
StoryMediaType: story.Type);
await _sender.Send(messageCommand, ct);
// 3. Notify story owner in real-time (existing StoryViewer listeners)
var replier = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
await _hubContext.Clients.All.SendAsync("story_reply", new
{
storyId = story.Id,
userId = _userContext.UserId,
username = replier?.Username,
displayName = replier?.DisplayName,
avatar = replier?.Avatar,
content = request.Content,
createdAt = DateTime.UtcNow,
ownerId = story.UserId
}, ct);
return Ok(new { message = "Reply added" });
}
catch (Exception ex)
{
_logger.LogError(ex, "AddReply error");
return StatusCode(500, ex.Message);
}
}
[HttpGet("{id}/replies")]
public async Task<IActionResult> GetReplies(Guid id, CancellationToken ct)
{
var story = await _context.Stories
.Include(s => s.Replies)
.FirstOrDefaultAsync(s => s.Id == id, ct);
if (story == null) return NotFound();
if (story.UserId != _userContext.UserId) return Forbid();
var replies = new List<object>();
foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt))
{
var user = await _userRepository.GetByIdAsync(reply.UserId, ct);
if (user == null) continue;
replies.Add(new
{
id = reply.Id,
userId = user.Id,
username = user.Username,
displayName = user.DisplayName,
avatar = user.Avatar,
content = reply.Content,
createdAt = reply.CreatedAt
});
}
return Ok(replies);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
@@ -205,6 +523,22 @@ public sealed class StoriesController : ControllerBase
return Ok(new { message = "Story deleted" });
}
private async Task<Guid> GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken ct)
{
var userChats = await _chatRepository.GetUserChatsAsync(userId1, ct);
var personalChat = userChats.FirstOrDefault(c =>
c.Type == ChatType.Personal &&
c.Members.Any(m => m.UserId == userId2));
if (personalChat != null) return personalChat.Id;
var command = new Vortex.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List<Guid> { userId1, userId2 });
var result = await _sender.Send(command, ct);
return result.Value;
}
}
public sealed record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor);
public sealed record AddStoryReactionRequest(string Emoji);
public sealed record RemoveStoryReactionRequest(string Emoji);
public sealed record AddStoryReplyRequest(string Content);
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Host")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+ca1d88191ca628dbe2371abab56fb3cdfe386d65")]
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Host")]
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50
f3d52bfd2b858d5d383380a9e4100efe419d613a4ecf13cf06c4ad546122d06b
@@ -1 +1 @@
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}}
@@ -1 +1 @@
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB