diff --git a/.gitignore b/.gitignore index 842a7a9..cf13ab2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ local.properties # QMAX runtime data and secrets data/ +!server/QMax.Api/Data/ +!server/QMax.Api/Data/** storage/ secrets/ .env diff --git a/server/QMax.Api/Data/Entities/Chat.cs b/server/QMax.Api/Data/Entities/Chat.cs new file mode 100644 index 0000000..2b541ea --- /dev/null +++ b/server/QMax.Api/Data/Entities/Chat.cs @@ -0,0 +1,19 @@ +namespace QMax.Api.Data.Entities; + +public sealed class Chat +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? ExternalId { get; set; } + public ChatKind Kind { get; set; } = ChatKind.MaxDialog; + public string Title { get; set; } = "MAX chat"; + public string? AvatarUrl { get; set; } + public string? LastMessagePreview { get; set; } + public DateTimeOffset? LastMessageAt { get; set; } + public int UnreadCount { get; set; } + public bool IsPinned { get; set; } + public bool IsMuted { get; set; } + public bool IsArchived { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public ICollection Messages { get; set; } = new List(); +} diff --git a/server/QMax.Api/Data/Entities/Enums.cs b/server/QMax.Api/Data/Entities/Enums.cs new file mode 100644 index 0000000..47bd166 --- /dev/null +++ b/server/QMax.Api/Data/Entities/Enums.cs @@ -0,0 +1,36 @@ +namespace QMax.Api.Data.Entities; + +public enum ChatKind +{ + Direct = 0, + Group = 1, + Channel = 2, + MaxDialog = 3 +} + +public enum MessageDirection +{ + Incoming = 0, + Outgoing = 1, + System = 2 +} + +public enum MessageDeliveryState +{ + Sending = 0, + Sent = 1, + Delivered = 2, + Read = 3, + Failed = 4 +} + +public enum AttachmentKind +{ + File = 0, + VoiceNote = 1, + Image = 2, + Video = 3, + Sticker = 4, + Gif = 5, + Contact = 6 +} diff --git a/server/QMax.Api/Data/Entities/MaxAccountState.cs b/server/QMax.Api/Data/Entities/MaxAccountState.cs new file mode 100644 index 0000000..65544a8 --- /dev/null +++ b/server/QMax.Api/Data/Entities/MaxAccountState.cs @@ -0,0 +1,12 @@ +namespace QMax.Api.Data.Entities; + +public sealed class MaxAccountState +{ + public int Id { get; set; } = 1; + public string PhoneNumber { get; set; } = ""; + public string Status { get; set; } = "NotStarted"; + public bool IsAuthorized { get; set; } + public string? LastUrl { get; set; } + public string? LastError { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/server/QMax.Api/Data/Entities/Message.cs b/server/QMax.Api/Data/Entities/Message.cs new file mode 100644 index 0000000..ec57fe1 --- /dev/null +++ b/server/QMax.Api/Data/Entities/Message.cs @@ -0,0 +1,23 @@ +namespace QMax.Api.Data.Entities; + +public sealed class Message +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat? Chat { get; set; } + public string? ExternalId { get; set; } + public string? SenderExternalId { get; set; } + public string? SenderName { get; set; } + public MessageDirection Direction { get; set; } + public string? Text { get; set; } + public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? EditedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public Guid? ReplyToMessageId { get; set; } + public string? ForwardedFrom { get; set; } + public MessageDeliveryState DeliveryState { get; set; } = MessageDeliveryState.Sent; + public string? Error { get; set; } + public string? MediaAlbumId { get; set; } + public ICollection Attachments { get; set; } = new List(); + public ICollection Reactions { get; set; } = new List(); +} diff --git a/server/QMax.Api/Data/Entities/MessageAttachment.cs b/server/QMax.Api/Data/Entities/MessageAttachment.cs new file mode 100644 index 0000000..1425359 --- /dev/null +++ b/server/QMax.Api/Data/Entities/MessageAttachment.cs @@ -0,0 +1,18 @@ +namespace QMax.Api.Data.Entities; + +public sealed class MessageAttachment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message? Message { get; set; } + public string? ExternalId { get; set; } + public string OriginalFileName { get; set; } = "file"; + public string StorageFileName { get; set; } = ""; + public string ContentType { get; set; } = "application/octet-stream"; + public long FileSizeBytes { get; set; } + public string? Sha256 { get; set; } + public AttachmentKind Kind { get; set; } = AttachmentKind.File; + public int SortOrder { get; set; } + public string? RemoteUrl { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/server/QMax.Api/Data/Entities/MessageReaction.cs b/server/QMax.Api/Data/Entities/MessageReaction.cs new file mode 100644 index 0000000..3fd13ff --- /dev/null +++ b/server/QMax.Api/Data/Entities/MessageReaction.cs @@ -0,0 +1,12 @@ +namespace QMax.Api.Data.Entities; + +public sealed class MessageReaction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message? Message { get; set; } + public string Emoji { get; set; } = ""; + public string ActorKey { get; set; } = "self"; + public string? ActorName { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/server/QMax.Api/Data/Entities/PushDevice.cs b/server/QMax.Api/Data/Entities/PushDevice.cs new file mode 100644 index 0000000..1bca38f --- /dev/null +++ b/server/QMax.Api/Data/Entities/PushDevice.cs @@ -0,0 +1,12 @@ +namespace QMax.Api.Data.Entities; + +public sealed class PushDevice +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User? User { get; set; } + public string FirebaseToken { get; set; } = ""; + public string Platform { get; set; } = "android"; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/server/QMax.Api/Data/Entities/User.cs b/server/QMax.Api/Data/Entities/User.cs new file mode 100644 index 0000000..7ecd842 --- /dev/null +++ b/server/QMax.Api/Data/Entities/User.cs @@ -0,0 +1,12 @@ +namespace QMax.Api.Data.Entities; + +public sealed class User +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string DisplayName { get; set; } = "QMAX Owner"; + public string? PhoneNumber { get; set; } + public string? AvatarPath { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + public ICollection Sessions { get; set; } = new List(); +} diff --git a/server/QMax.Api/Data/Entities/UserSession.cs b/server/QMax.Api/Data/Entities/UserSession.cs new file mode 100644 index 0000000..e5557c8 --- /dev/null +++ b/server/QMax.Api/Data/Entities/UserSession.cs @@ -0,0 +1,14 @@ +namespace QMax.Api.Data.Entities; + +public sealed class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User? User { get; set; } + public string DeviceName { get; set; } = "Android"; + public string RefreshTokenHash { get; set; } = ""; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddDays(90); + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/server/QMax.Api/Data/QMaxDatabaseCleanup.cs b/server/QMax.Api/Data/QMaxDatabaseCleanup.cs new file mode 100644 index 0000000..58426c3 --- /dev/null +++ b/server/QMax.Api/Data/QMaxDatabaseCleanup.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using QMax.Api.Data.Entities; + +namespace QMax.Api.Data; + +public static class QMaxDatabaseCleanup +{ + public static async Task RemoveLegacyWebFileAttachmentEchoesAsync(QMaxDbContext db) + { + var connection = db.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(); + } + + await db.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS temp.LegacyWebFileMessageIds;"); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TEMP TABLE LegacyWebFileMessageIds ( + Id TEXT NOT NULL PRIMARY KEY + ); + """); + await db.Database.ExecuteSqlRawAsync(""" + INSERT OR IGNORE INTO LegacyWebFileMessageIds (Id) + SELECT DISTINCT legacy.Id + FROM Messages AS legacy + INNER JOIN MessageAttachments AS legacyAttachment ON legacyAttachment.MessageId = legacy.Id + WHERE legacy.Direction = 'Outgoing' + AND legacy.ExternalId LIKE 'web-file-%' + AND legacyAttachment.Sha256 IS NOT NULL + AND legacyAttachment.Sha256 <> '' + AND EXISTS ( + SELECT 1 + FROM Messages AS confirmed + INNER JOIN MessageAttachments AS confirmedAttachment ON confirmedAttachment.MessageId = confirmed.Id + WHERE confirmed.ChatId = legacy.ChatId + AND confirmed.Id <> legacy.Id + AND confirmed.Direction = 'Outgoing' + AND confirmed.ExternalId IS NOT NULL + AND confirmed.ExternalId NOT LIKE 'web-file-%' + AND confirmedAttachment.Sha256 = legacyAttachment.Sha256 + ); + """); + await db.Database.ExecuteSqlRawAsync(""" + DELETE FROM MessageAttachments + WHERE MessageId IN (SELECT Id FROM LegacyWebFileMessageIds); + """); + await db.Database.ExecuteSqlRawAsync(""" + DELETE FROM Messages + WHERE Id IN (SELECT Id FROM LegacyWebFileMessageIds); + """); + await db.Database.ExecuteSqlRawAsync("DROP TABLE IF EXISTS temp.LegacyWebFileMessageIds;"); + } + + public static async Task ClearUnreadCountsForLatestOutgoingChatsAsync(QMaxDbContext db) + { + var unreadChats = await db.Chats + .Where(chat => chat.UnreadCount > 0) + .ToListAsync(); + + if (unreadChats.Count == 0) + { + return; + } + + var changed = false; + foreach (var chat in unreadChats) + { + var chatMessages = await db.Messages + .Where(message => message.ChatId == chat.Id && message.DeletedAt == null) + .Select(message => new { message.Direction, message.SentAt, message.Id }) + .ToListAsync(); + + var latestMessage = chatMessages + .OrderByDescending(message => message.SentAt) + .ThenByDescending(message => message.Id) + .FirstOrDefault(); + + if (latestMessage?.Direction != MessageDirection.Outgoing) + { + continue; + } + + chat.UnreadCount = 0; + chat.UpdatedAt = DateTimeOffset.UtcNow; + changed = true; + } + + if (changed) + { + await db.SaveChangesAsync(); + } + } +} diff --git a/server/QMax.Api/Data/QMaxDbContext.cs b/server/QMax.Api/Data/QMaxDbContext.cs new file mode 100644 index 0000000..218b976 --- /dev/null +++ b/server/QMax.Api/Data/QMaxDbContext.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using QMax.Api.Data.Entities; + +namespace QMax.Api.Data; + +public sealed class QMaxDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet UserSessions => Set(); + public DbSet Chats => Set(); + public DbSet Messages => Set(); + public DbSet MessageAttachments => Set(); + public DbSet MessageReactions => Set(); + public DbSet PushDevices => Set(); + public DbSet MaxAccountStates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(x => x.PhoneNumber); + modelBuilder.Entity().HasIndex(x => x.RefreshTokenHash).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.ExternalId).IsUnique(); + modelBuilder.Entity().HasIndex(x => new { x.ChatId, x.SentAt }); + modelBuilder.Entity().HasIndex(x => x.ExternalId); + modelBuilder.Entity().HasIndex(x => x.StorageFileName).IsUnique(); + modelBuilder.Entity().HasIndex(x => new { x.MessageId, x.ExternalId }); + modelBuilder.Entity().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.FirebaseToken).IsUnique(); + + modelBuilder.Entity() + .Property(x => x.Kind) + .HasConversion(); + + modelBuilder.Entity() + .Property(x => x.Direction) + .HasConversion(); + + modelBuilder.Entity() + .Property(x => x.DeliveryState) + .HasConversion(); + + modelBuilder.Entity() + .Property(x => x.Kind) + .HasConversion(); + + modelBuilder.Entity() + .HasMany(x => x.Attachments) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Reactions) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index 2373b07..d6f948c 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -161,6 +161,8 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) } await RemoveDuplicateMessageAttachmentsAsync(db); + await QMaxDatabaseCleanup.RemoveLegacyWebFileAttachmentEchoesAsync(db); + await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(db); await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;"); await db.Database.ExecuteSqlRawAsync(""" CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 59286df..565fa58 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -712,7 +712,7 @@ public sealed class ApiSmokeTests : IDisposable { Message = message, OriginalFileName = "sent-photo.jpg", - StorageFileName = "sent-photo.jpg", + StorageFileName = $"{Guid.NewGuid():N}-sent-photo.jpg", ContentType = "image/jpeg", FileSizeBytes = 1024, Kind = AttachmentKind.Image @@ -791,6 +791,94 @@ public sealed class ApiSmokeTests : IDisposable } } + [Fact] + public async Task DatabaseCleanupRemovesLegacyWebFileAttachmentDuplicateAndClearsSelfUnread() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"qmax-legacy-web-file-test-{Guid.NewGuid():N}.db"); + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath};Pooling=False") + .Options; + var chatId = Guid.NewGuid(); + const string attachmentHash = "same-photo-sha"; + var legacySentAt = DateTimeOffset.UtcNow.AddMinutes(-40); + + try + { + await using (var db = new QMaxDbContext(dbOptions)) + { + await db.Database.EnsureCreatedAsync(); + db.Chats.Add(new Chat + { + Id = chatId, + Title = "Legacy Photo", + LastMessagePreview = "\u041c\u0435\u0434\u0438\u0430", + LastMessageAt = legacySentAt.AddMinutes(30), + UnreadCount = 2 + }); + + var legacyMessage = new Message + { + ChatId = chatId, + ExternalId = "web-file-123", + Direction = MessageDirection.Outgoing, + SentAt = legacySentAt, + DeliveryState = MessageDeliveryState.Sent + }; + legacyMessage.Attachments.Add(new MessageAttachment + { + OriginalFileName = "legacy.jpg", + StorageFileName = $"{Guid.NewGuid():N}.jpg", + ContentType = "image/jpeg", + FileSizeBytes = 10, + Sha256 = attachmentHash, + Kind = AttachmentKind.Image + }); + + var confirmedMessage = new Message + { + ChatId = chatId, + ExternalId = "domhist:real-photo", + Direction = MessageDirection.Outgoing, + SentAt = legacySentAt.AddMinutes(30), + DeliveryState = MessageDeliveryState.Sent + }; + confirmedMessage.Attachments.Add(new MessageAttachment + { + OriginalFileName = "confirmed.jpg", + StorageFileName = $"{Guid.NewGuid():N}.jpg", + ContentType = "image/jpeg", + FileSizeBytes = 10, + Sha256 = attachmentHash, + Kind = AttachmentKind.Image + }); + + db.Messages.AddRange(legacyMessage, confirmedMessage); + await db.SaveChangesAsync(); + } + + await using (var cleanupDb = new QMaxDbContext(dbOptions)) + { + await QMaxDatabaseCleanup.RemoveLegacyWebFileAttachmentEchoesAsync(cleanupDb); + await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(cleanupDb); + } + + await using var verifyDb = new QMaxDbContext(dbOptions); + var messages = await verifyDb.Messages + .Where(message => message.ChatId == chatId) + .ToListAsync(); + Assert.DoesNotContain(messages, message => message.ExternalId == "web-file-123"); + Assert.Contains(messages, message => message.ExternalId == "domhist:real-photo"); + + var chat = await verifyDb.Chats.FindAsync(chatId); + Assert.NotNull(chat); + Assert.Equal(0, chat!.UnreadCount); + } + finally + { + DeleteSqliteDatabase(databasePath); + } + } + [Fact] public async Task AdminStatusRequiresPairingCode() { @@ -832,9 +920,14 @@ public sealed class ApiSmokeTests : IDisposable public void Dispose() { _factory.Dispose(); + DeleteSqliteDatabase(_databasePath); + } + + private static void DeleteSqliteDatabase(string databasePath) + { foreach (var suffix in new[] { "", "-shm", "-wal" }) { - var path = _databasePath + suffix; + var path = databasePath + suffix; if (File.Exists(path)) { File.Delete(path);