using LiteDB; using static Mocha2023.Classes.DBs.DBClasses.ClubDBClasses; namespace Mocha2023.Classes.DBs { public static class ClubDB { private const int MaxCreatedClubsPerAccount = 100; private static readonly object Sync = new(); private static readonly LiteDatabase Database = new(Path.Combine(Program.dataDir, "DBs", "Clubs.db")); public static readonly ILiteCollection Clubs = Database.GetCollection("Clubs"); public static readonly ILiteCollection Memberships = Database.GetCollection("Memberships"); public static readonly ILiteCollection HomeClubs = Database.GetCollection("HomeClubs"); public static readonly IReadOnlyList CategoryTags = new[] { "Social", "Creative", "Competitive", "Casual", "Educational", "Entertainment", "Lifestyle" }; private static bool _legacyDataRepaired; static ClubDB() { Clubs.EnsureIndex(value => value.CreatorAccountId); Clubs.EnsureIndex(value => value.Name); Clubs.EnsureIndex(value => value.State); Memberships.EnsureIndex(value => value.ClubId); Memberships.EnsureIndex(value => value.AccountId); Memberships.EnsureIndex(value => value.MembershipType); RepairLegacyData(); } public static void RepairLegacyData() { lock (Sync) { if (_legacyDataRepaired) return; int clubsChanged = 0; int membershipsChanged = 0; int malformedMembershipsRemoved = 0; foreach (ClubMembershipRecord membership in Memberships.FindAll().ToList()) { if (membership.ClubId <= 0 || membership.AccountId <= 0) { if (!string.IsNullOrWhiteSpace(membership.Id)) Memberships.Delete(membership.Id); malformedMembershipsRemoved++; continue; } string expectedId = MembershipId(membership.ClubId, membership.AccountId); if (string.Equals(membership.Id, expectedId, StringComparison.Ordinal)) continue; ClubMembershipRecord? existing = Memberships.FindById(expectedId); ClubMembershipType mergedType = existing == null ? membership.MembershipType : (ClubMembershipType)Math.Max( (int)existing.MembershipType, (int)membership.MembershipType); if (!string.IsNullOrWhiteSpace(membership.Id)) Memberships.Delete(membership.Id); UpsertMembershipUnsafe( membership.ClubId, membership.AccountId, mergedType); membershipsChanged++; } foreach (ClubRecord club in Clubs.FindAll().ToList()) { bool changed = false; club.Name ??= string.Empty; club.Description ??= string.Empty; club.MainImageName = NormalizeImageName(club.MainImageName); club.AdditionalImages ??= new List(); club.CategoryTags ??= new List(); string resolvedPrimary = ResolveLegacyPrimaryCategory( club.PrimaryCategory, club.CategoryTags); List normalizedTags = NormalizeTags( new[] { resolvedPrimary } .Concat(club.CategoryTags)) .ToList(); if (normalizedTags.Count == 0) normalizedTags.Add(resolvedPrimary); if (!string.Equals( club.PrimaryCategory, resolvedPrimary, StringComparison.Ordinal)) { Console.WriteLine( $"[CLUB CATEGORY REPAIR] club={club.ClubId} " + $"old={club.PrimaryCategory ?? "null"} new={resolvedPrimary} " + $"tags={string.Join(',', club.CategoryTags)}"); club.PrimaryCategory = resolvedPrimary; changed = true; } if (!club.CategoryTags.SequenceEqual( normalizedTags, StringComparer.OrdinalIgnoreCase)) { club.CategoryTags = normalizedTags; changed = true; } ClubMembershipRecord? creatorMembership = Memberships.FindById(MembershipId( club.ClubId, club.CreatorAccountId)); if (club.CreatorAccountId > 0 && creatorMembership?.MembershipType != ClubMembershipType.Creator) { UpsertMembershipUnsafe( club.ClubId, club.CreatorAccountId, ClubMembershipType.Creator); membershipsChanged++; } int actualMemberCount = Memberships.Count(value => value.ClubId == club.ClubId && value.MembershipType >= ClubMembershipType.Member); if (club.MemberCount != actualMemberCount) { club.MemberCount = actualMemberCount; changed = true; } if (changed) { club.UpdatedAt = DateTime.UtcNow; Clubs.Update(club); clubsChanged++; } } foreach (HomeClubRecord home in HomeClubs.FindAll().ToList()) { ClubRecord? club = Clubs.FindById(home.ClubId); if (club == null) { HomeClubs.Delete(home.AccountId); continue; } ClubMembershipRecord? membership = Memberships.FindById(MembershipId( home.ClubId, home.AccountId)); if (membership == null || membership.MembershipType < ClubMembershipType.Member) { ClubMembershipType type = club.CreatorAccountId == home.AccountId ? ClubMembershipType.Creator : ClubMembershipType.Member; UpsertMembershipUnsafe(home.ClubId, home.AccountId, type); membershipsChanged++; } } foreach (ClubRecord club in Clubs.FindAll().ToList()) { int actualMemberCount = Memberships.Count(value => value.ClubId == club.ClubId && value.MembershipType >= ClubMembershipType.Member); if (club.MemberCount == actualMemberCount) continue; club.MemberCount = actualMemberCount; club.UpdatedAt = DateTime.UtcNow; Clubs.Update(club); clubsChanged++; } _legacyDataRepaired = true; Console.WriteLine( $"[CLUB REPAIR] clubs={clubsChanged} " + $"memberships={membershipsChanged} " + $"malformedRemoved={malformedMembershipsRemoved}"); } } public static bool TryCreate( long creatorAccountId, string name, string description, string? mainImageName, ClubVisibility visibility, ClubJoinability joinability, bool allowJuniors, int minLevel, ClubType clubType, IEnumerable? categoryTags, out ClubRecord? club, out string? error) { lock (Sync) { club = null; error = null; string normalizedName = name.Trim(); ClubRecord? existingOwnedClub = Clubs.FindAll().FirstOrDefault(value => value.CreatorAccountId == creatorAccountId && value.State != ClubState.MarkedForDelete && string.Equals( value.Name, normalizedName, StringComparison.OrdinalIgnoreCase)); if (existingOwnedClub != null) { UpsertMembershipUnsafe( existingOwnedClub.ClubId, creatorAccountId, ClubMembershipType.Creator); club = existingOwnedClub; return true; } if (Clubs.Count(value => value.CreatorAccountId == creatorAccountId && value.State != ClubState.MarkedForDelete) >= MaxCreatedClubsPerAccount) { error = "club_creation_limit"; return false; } List tags = NormalizeTags(categoryTags).ToList(); if (tags.Count == 0) tags.Add("Social"); club = new ClubRecord { ClubId = NextClubId(), Name = normalizedName, Description = description.Trim(), MainImageName = NormalizeImageName(mainImageName), MemberCount = 1, PrimaryCategory = tags[0], Visibility = visibility, Joinability = joinability, AllowJuniors = allowJuniors, MinLevel = Math.Clamp(minLevel, 0, 50), ClubType = clubType, CreatorAccountId = creatorAccountId, CategoryTags = tags, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; Clubs.Insert(club); UpsertMembershipUnsafe( club.ClubId, creatorAccountId, ClubMembershipType.Creator); HomeClubs.Upsert(new HomeClubRecord { AccountId = creatorAccountId, ClubId = club.ClubId, UpdatedAt = DateTime.UtcNow }); return true; } } public static ClubRecord? Get(long clubId) { lock (Sync) return Clubs.FindById(clubId); } public static List GetCreatedBy(long accountId) { lock (Sync) return Clubs.Find(value => value.CreatorAccountId == accountId && value.State != ClubState.MarkedForDelete) .OrderByDescending(value => value.CreatedAt) .ToList(); } public static List GetMemberClubs(long accountId) { lock (Sync) { HashSet clubIds = Memberships.Find(value => value.AccountId == accountId && value.MembershipType >= ClubMembershipType.Member) .Select(value => value.ClubId) .ToHashSet(); long? homeClubId = HomeClubs.FindById(accountId)?.ClubId; return Clubs.Find(value => value.State == ClubState.Active) .Where(value => clubIds.Contains(value.ClubId) || value.CreatorAccountId == accountId || homeClubId == value.ClubId) .OrderByDescending(value => value.MemberCount) .ThenBy(value => value.Name) .ToList(); } } public static List Search( string? category, string? query, int sort, int skip, int count) => SearchWithTotal(category, query, sort, skip, count).Results; public static (List Results, int TotalResults) SearchWithTotal( string? category, string? query, int sort, int skip, int count) { lock (Sync) { IEnumerable clubs = Clubs.Find(value => value.State == ClubState.Active && value.Visibility == ClubVisibility.Public); string? normalizedCategory = NormalizeCategory(category); if (!string.IsNullOrWhiteSpace(normalizedCategory)) { clubs = clubs.Where(value => ClubMatchesCategory(value, normalizedCategory)); } if (!string.IsNullOrWhiteSpace(query)) { string trimmedQuery = query.Trim(); clubs = clubs.Where(value => (value.Name ?? string.Empty).Contains( trimmedQuery, StringComparison.OrdinalIgnoreCase) || (value.Description ?? string.Empty).Contains( trimmedQuery, StringComparison.OrdinalIgnoreCase)); } clubs = sort switch { 1 => clubs.OrderByDescending(value => value.CreatedAt), 2 => clubs.OrderBy(value => value.CreatedAt), _ => clubs.OrderByDescending(value => value.MemberCount) .ThenBy(value => value.Name) }; List all = clubs.ToList(); List page = all .Skip(Math.Max(0, skip)) .Take(Math.Clamp(count, 1, 100)) .ToList(); return (page, all.Count); } } public static ClubMembershipRecord? GetMembership(long clubId, long accountId) { lock (Sync) { ClubMembershipRecord? membership = Memberships.FindById(MembershipId(clubId, accountId)); if (membership != null) return membership; ClubRecord? club = Clubs.FindById(clubId); if (club?.CreatorAccountId != accountId) return null; UpsertMembershipUnsafe( clubId, accountId, ClubMembershipType.Creator); return Memberships.FindById(MembershipId(clubId, accountId)); } } public static List GetMemberships( long clubId, bool includePending = false) { lock (Sync) { return Memberships.Find(value => value.ClubId == clubId && (includePending || value.MembershipType >= ClubMembershipType.Member)) .OrderByDescending(value => value.MembershipType) .ThenBy(value => value.CreatedAt) .ToList(); } } public static bool SetMembership( long clubId, long accountId, ClubMembershipType membershipType) { lock (Sync) { if (Clubs.FindById(clubId) == null) return false; UpsertMembershipUnsafe(clubId, accountId, membershipType); RefreshMemberCountUnsafe(clubId); return true; } } public static bool RemoveMembership(long clubId, long accountId) { lock (Sync) { ClubRecord? club = Clubs.FindById(clubId); if (club == null || club.CreatorAccountId == accountId) return false; Memberships.Delete(MembershipId(clubId, accountId)); if (HomeClubs.FindById(accountId)?.ClubId == clubId) HomeClubs.Delete(accountId); RefreshMemberCountUnsafe(clubId); return true; } } public static bool SetHomeClub(long accountId, long? clubId) { lock (Sync) { if (!clubId.HasValue || clubId.Value <= 0) return HomeClubs.Delete(accountId); ClubMembershipRecord? membership = GetMembership(clubId.Value, accountId); if (membership == null || membership.MembershipType < ClubMembershipType.Member) return false; HomeClubs.Upsert(new HomeClubRecord { AccountId = accountId, ClubId = clubId.Value, UpdatedAt = DateTime.UtcNow }); return true; } } public static ClubRecord? GetHomeClub(long accountId) { lock (Sync) { HomeClubRecord? home = HomeClubs.FindById(accountId); return home == null ? null : Clubs.FindById(home.ClubId); } } public static bool Update(ClubRecord club) { lock (Sync) { club.Name = (club.Name ?? string.Empty).Trim(); club.Description = (club.Description ?? string.Empty).Trim(); club.MainImageName = NormalizeImageName(club.MainImageName); string primary = NormalizeCategory(club.PrimaryCategory) ?? NormalizeTags(club.CategoryTags).FirstOrDefault() ?? "Social"; List normalizedTags = NormalizeTags( new[] { primary } .Concat(club.CategoryTags ?? new List())) .ToList(); if (normalizedTags.Count == 0) normalizedTags.Add(primary); club.PrimaryCategory = primary; club.CategoryTags = normalizedTags; club.MinLevel = Math.Clamp(club.MinLevel, 0, 50); club.UpdatedAt = DateTime.UtcNow; return Clubs.Update(club); } } public static bool Delete(long clubId, long accountId) { lock (Sync) { ClubRecord? club = Clubs.FindById(clubId); if (club == null || club.CreatorAccountId != accountId) return false; Clubs.Delete(clubId); foreach (ClubMembershipRecord membership in Memberships.Find(value => value.ClubId == clubId).ToList()) { Memberships.Delete(membership.Id); } foreach (HomeClubRecord home in HomeClubs.Find(value => value.ClubId == clubId).ToList()) { HomeClubs.Delete(home.AccountId); } return true; } } public static ClubSummaryDto ToSummary(ClubRecord club) => ToSummary(club, null); public static ClubSummaryDto ToSummary( ClubRecord club, long? viewerAccountId) { ClubMembershipType membershipType = ClubMembershipType.None; if (viewerAccountId.HasValue) { membershipType = GetMembership( club.ClubId, viewerAccountId.Value)?.MembershipType ?? (club.CreatorAccountId == viewerAccountId.Value ? ClubMembershipType.Creator : ClubMembershipType.None); } string primary = NormalizeCategory(club.PrimaryCategory) ?? NormalizeTags(club.CategoryTags).FirstOrDefault() ?? "Social"; List normalizedTags = NormalizeTags( new[] { primary } .Concat(club.CategoryTags ?? new List())) .ToList(); if (normalizedTags.Count == 0) normalizedTags.Add(primary); return new ClubSummaryDto { ClubId = club.ClubId, Name = club.Name ?? string.Empty, Description = club.Description ?? string.Empty, MainImageName = NormalizeImageName(club.MainImageName), State = (int)club.State, MemberCount = club.MemberCount, PrimaryCategory = primary, CategoryTags = normalizedTags, Visibility = (int)club.Visibility, Joinability = (int)club.Joinability, AllowJuniors = club.AllowJuniors, MinLevel = club.MinLevel, ClubChatEnabled = club.ClubChatEnabled, ClubhouseRoomId = club.ClubhouseRoomId, ClubType = (int)club.ClubType, CreatorAccountId = club.CreatorAccountId, ClubMembershipType = (int)membershipType, CreatedAt = club.CreatedAt, UpdatedAt = club.UpdatedAt }; } public static ClubMembershipDto ToMembership(ClubMembershipRecord membership) => new() { AccountId = membership.AccountId, ClubId = membership.ClubId, ClubMembershipType = (int)membership.MembershipType, CreatedAt = membership.CreatedAt }; public static ClubDetailsDto ToDetails(ClubRecord club, long? viewerAccountId) { int myType = viewerAccountId.HasValue ? (int)(GetMembership(club.ClubId, viewerAccountId.Value)?.MembershipType ?? ClubMembershipType.None) : (int)ClubMembershipType.None; return new ClubDetailsDto { Club = ToSummary(club, viewerAccountId), CategoryTags = ToSummary(club).CategoryTags, AdditionalImages = (club.AdditionalImages ?? new List()) .Select((imageName, imageIndex) => new ClubImageDto { ImageName = imageName, ImageIndex = imageIndex }) .ToList(), CreatorPermissions = Permissions(club.ClubId, ClubMembershipType.Creator), CoOwnerPermissions = Permissions(club.ClubId, ClubMembershipType.CoOwner), ModeratorPermissions = Permissions(club.ClubId, ClubMembershipType.Moderator), MyMembershipType = myType }; } public static ClubPermissionsDto Permissions( long clubId, ClubMembershipType membershipType) { bool moderator = membershipType >= ClubMembershipType.Moderator; bool coOwner = membershipType >= ClubMembershipType.CoOwner; return new ClubPermissionsDto { ClubId = clubId, MembershipType = (int)membershipType, CanInvite = moderator, CanKick = moderator, CanBan = moderator, CanModify = coOwner, CanManageRoles = coOwner, CanManageClubhouse = coOwner }; } private static string ResolveLegacyPrimaryCategory( string? storedPrimary, IEnumerable? storedTags) { string? primary = NormalizeCategory(storedPrimary); List tags = NormalizeTags(storedTags).ToList(); if (primary == null) return tags.FirstOrDefault() ?? "Social"; if (string.Equals(primary, "Social", StringComparison.OrdinalIgnoreCase)) { string? recovered = tags.FirstOrDefault(value => !string.Equals(value, "Social", StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(recovered)) return recovered; } return primary; } private static bool ClubMatchesCategory( ClubRecord club, string normalizedCategory) { string primary = NormalizeCategory(club.PrimaryCategory) ?? "Social"; if (string.Equals(primary, normalizedCategory, StringComparison.OrdinalIgnoreCase)) return true; return (club.CategoryTags ?? new List()) .Select(NormalizeCategory) .Any(tag => string.Equals( tag, normalizedCategory, StringComparison.OrdinalIgnoreCase)); } private static long NextClubId() => Clubs.Count() == 0 ? 1 : Clubs.Max(value => value.ClubId) + 1; private static string MembershipId(long clubId, long accountId) => $"{clubId}:{accountId}"; private static void UpsertMembershipUnsafe( long clubId, long accountId, ClubMembershipType membershipType) { string id = MembershipId(clubId, accountId); ClubMembershipRecord record = Memberships.FindById(id) ?? new ClubMembershipRecord { Id = id, ClubId = clubId, AccountId = accountId, CreatedAt = DateTime.UtcNow }; record.Id = id; record.ClubId = clubId; record.AccountId = accountId; record.MembershipType = membershipType; record.UpdatedAt = DateTime.UtcNow; Memberships.Upsert(record); } private static void RefreshMemberCountUnsafe(long clubId) { ClubRecord? club = Clubs.FindById(clubId); if (club == null) return; club.MemberCount = Memberships.Count(value => value.ClubId == clubId && value.MembershipType >= ClubMembershipType.Member); club.UpdatedAt = DateTime.UtcNow; Clubs.Update(club); } private static IEnumerable NormalizeTags(IEnumerable? tags) => (tags ?? Array.Empty()) .Select(NormalizeCategory) .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value!) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3); private static string? NormalizeCategory(string? category) { if (string.IsNullOrWhiteSpace(category)) return null; return category.Trim().ToLowerInvariant() switch { "social" => "Social", "hangout" => "Social", "creative" => "Creative", "art" => "Creative", "competitive" => "Competitive", "casual" => "Casual", "games" => "Casual", "roleplay" => "Casual", "educational" => "Educational", "education" => "Educational", "entertainment" => "Entertainment", "quest" => "Entertainment", "music" => "Entertainment", "lifestyle" => "Lifestyle", _ => null }; } private static string NormalizeImageName(string? imageName) { string normalized = (imageName ?? string.Empty).Trim().Replace('\\', '/'); if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > 260 || normalized.Contains("..", StringComparison.Ordinal) || Uri.TryCreate(normalized, UriKind.Absolute, out _)) return "DefaultClubImage2k.jpg"; return normalized.TrimStart('/'); } } }