using Microsoft.EntityFrameworkCore; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Services; /// /// Backs the clubs.localhost/* surface used by the 2020.12 watch: /// * GET /club/home/me — composite home tab payload /// * GET /club/mine/member — clubs the player is in /// * GET /club/mine/created — clubs the player created /// * GET /club/categoryTags — admin-curated tag list /// * GET /announcements/v2/mine/unread — direct feed /// * GET /announcements/v2/subscription/mine/unread — subs feed /// /// Owns no in-memory state — every method round-trips the DbContext so /// reads stay consistent across replicas. The legacy /// GroupsController reads + writes the same /// table, so club rows created via api/groups/v1/ show up here /// automatically without backfill. /// public class ClubService(DorkNetDbContext db) { /// Clubs the player is a member of (any permissions /// value above 0 OR a Member=0 row — i.e. presence in the /// table). The owner's /// auto-membership row inserted by GroupsController.Create /// (Permissions=127) also matches. public Task> MyClubsAsync(long playerId) => (from m in db.ClubMemberships join c in db.Clubs on m.ClubId equals c.Id where m.PlayerId == playerId && c.State == 0 orderby c.Name select c).ToListAsync(); /// Clubs whose /// equals the player. The owner usually also has a membership row, /// so + this overlap — the watch's /// "Mine" tab renders the union and de-dupes by ClubId. public Task> CreatedByAsync(long playerId) => db.Clubs .Where(c => c.CreatorPlayerId == playerId && c.State == 0) .OrderBy(c => c.Name) .ToListAsync(); /// /// Unread announcements for clubs the player is a MEMBER of. Drives /// announcements/v2/mine/unread. The "unread" filter is /// LEFT JOIN against for /// the calling player — rows with no read-marker count as unread. /// public async Task> UnreadDirectAsync(long playerId) { var memberClubIds = await db.ClubMemberships .Where(m => m.PlayerId == playerId) .Select(m => m.ClubId) .ToListAsync(); if (memberClubIds.Count == 0) return new(); return await UnreadForClubsAsync(playerId, memberClubIds); } /// /// Unread announcements for clubs the player SUBSCRIBES to (rows /// in ). Drives /// announcements/v2/subscription/mine/unread. Same unread /// semantics as the direct feed. /// public async Task> UnreadSubscriptionAsync(long playerId) { var subClubIds = await db.ClubSubscriptions .Where(s => s.PlayerId == playerId) .Select(s => s.ClubId) .ToListAsync(); if (subClubIds.Count == 0) return new(); return await UnreadForClubsAsync(playerId, subClubIds); } private async Task> UnreadForClubsAsync( long playerId, List clubIds) { // Pull all candidate announcements + the player's read-markers // for them in one pass each. EF Core's LEFT-JOIN over a // subquery generates pathological SQL on SQLite, so the // anti-join is done in memory after two index-supported reads. var candidates = await db.ClubAnnouncements .Where(a => clubIds.Contains(a.ClubId)) .OrderByDescending(a => a.CreatedAt) .ToListAsync(); if (candidates.Count == 0) return candidates; var candidateIds = candidates.Select(a => a.Id).ToList(); var readIds = await db.ClubAnnouncementReads .Where(r => r.PlayerId == playerId && candidateIds.Contains(r.AnnouncementId)) .Select(r => r.AnnouncementId) .ToListAsync(); var readSet = new HashSet(readIds); return candidates.Where(a => !readSet.Contains(a.Id)).ToList(); } /// /// Active category tags in display order. The 2020.12 watch /// deserialises the response as List<String>, so the /// controller projects /// out — the Id/OrderIndex/Active columns are admin-facing only. /// public Task> CategoryTagsAsync() => db.ClubCategoryTags .Where(t => t.Active) .OrderBy(t => t.OrderIndex) .ThenBy(t => t.Name) .ToListAsync(); /// /// Discover-tab search for GET /club/search. The 2020.12 /// watch can send either a category-only browse request or a text /// search; the rows are projected by the controller into the /// CAJHIEENJJD search envelope. /// public async Task<(List Clubs, int TotalClubs)> SearchAsync( string? query, string? category, int sort, int count) { var take = Math.Clamp(count <= 0 ? 30 : count, 1, 100); var q = db.Clubs.Where(c => c.State == 0); if (!string.IsNullOrWhiteSpace(category)) { var categoryLower = category.Trim().ToLower(); q = q.Where(c => c.Category.ToLower() == categoryLower); } if (!string.IsNullOrWhiteSpace(query)) { var queryLower = query.Trim().ToLower(); q = q.Where(c => c.Name.ToLower().Contains(queryLower) || c.Description.ToLower().Contains(queryLower)); } var total = await q.CountAsync(); q = sort switch { 1 => q.OrderByDescending(c => c.CreatedAt).ThenBy(c => c.Name), 2 => q.OrderBy(c => c.Name), _ => q.OrderByDescending(c => c.ClubType) .ThenByDescending(c => c.UpdatedAt) .ThenBy(c => c.Name), }; var clubs = await q.Take(take).ToListAsync(); return (clubs, total); } /// /// Clubs the player is subscribed to, in the lowercase-key wire /// shape LNIKPLKOBDK expects per /// dist/.../IsilDump/Assembly-CSharp/LNIKPLKOBDK.txt:110-124: /// {accountId, clubId, subscriberCount}. One row per /// (player, club) subscription row. subscriberCount is the /// total subscribers for that club across all players — derived /// live from the same table so it stays in sync without a cached /// counter that could drift. /// public async Task> MySubscriptionsAsync(long playerId) { var mine = await db.ClubSubscriptions .Where(s => s.PlayerId == playerId) .Select(s => s.ClubId) .ToListAsync(); if (mine.Count == 0) return new List(); var counts = await db.ClubSubscriptions .Where(s => mine.Contains(s.ClubId)) .GroupBy(s => s.ClubId) .Select(g => new { ClubId = g.Key, Count = g.Count() }) .ToListAsync(); var byClub = counts.ToDictionary(c => c.ClubId, c => c.Count); return mine .Select(clubId => (object)new { accountId = playerId, clubId = clubId, subscriberCount = byClub.TryGetValue(clubId, out var c) ? c : 0, }) .ToList(); } /// /// Composite home-tab payload for GET /club/home/me. The /// watch's deserialiser for this endpoint expects a single /// RecNet.Club object (typeof(System.Action`1<PLILLKHMNDA>) /// per ISIL) — typically the player's "home club" (set via /// SetMyHomeClub) rendered as the headline card. We pick the /// first club the player is a member of, ordered by membership /// recency. Returns null when the player has no club memberships /// — the controller turns that into /// so the watch knows to render the "join a club" empty state. /// public async Task HomeClubAsync(long playerId) { // Most-recent-joined club wins. Owners are members too (the // /create flow inserts an owner-permissions=127 membership // row), so a brand-new club creator sees their fresh club here. var pick = await (from m in db.ClubMemberships join c in db.Clubs on m.ClubId equals c.Id where m.PlayerId == playerId && c.State == 0 orderby m.JoinedAt descending select c).FirstOrDefaultAsync(); return pick; } /// /// Upserts a read-marker so subsequent /// / calls skip the /// announcement. Idempotent — calling twice is a no-op (the unique /// index on (AnnouncementId, PlayerId) guarantees one row). /// public async Task MarkAnnouncementReadAsync(long playerId, long announcementId) { var existing = await db.ClubAnnouncementReads .FirstOrDefaultAsync(r => r.PlayerId == playerId && r.AnnouncementId == announcementId); if (existing is not null) return; db.ClubAnnouncementReads.Add(new ClubAnnouncementReadEntity { AnnouncementId = announcementId, PlayerId = playerId, ReadAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } /// /// Live count of approved members for a club (used by the wire /// MemberCount int — see doc). /// public Task MemberCountAsync(long clubId) => db.ClubMemberships.CountAsync(m => m.ClubId == clubId); /// Subscribers TO a club (everyone who hit subscribe on /// the club page). Backs /subscription/subscriberCount/{id} /// when the id resolves to a club. public Task ClubSubscriberCountAsync(long clubId) => db.ClubSubscriptions.CountAsync(s => s.ClubId == clubId); /// Subscribers TO a player (everyone who hit subscribe on /// the player's profile — the pre-club subscription model in /// ). Backs /// /subscription/subscriberCount/{id} when the id resolves /// to a player account. public Task PlayerSubscriberCountAsync(long playerId) => db.Subscriptions.CountAsync(s => s.TargetPlayerId == playerId); /// How many player/profile subscriptions this account has /// made. public Task PlayerSubscribedCountAsync(long playerId) => db.Subscriptions.CountAsync(s => s.SubscriberPlayerId == playerId); public Task IsSubscribedToPlayerAsync(long subscriberId, long targetPlayerId) => db.Subscriptions.AnyAsync(s => s.SubscriberPlayerId == subscriberId && s.TargetPlayerId == targetPlayerId); /// /// Bulk member-count lookup so the controller can project a list /// of clubs in one round-trip. Returns a dict keyed by ClubId; any /// club without members maps to 0 in the caller's projection. /// public async Task> MemberCountsAsync(IEnumerable clubIds) { var ids = clubIds.Distinct().ToList(); if (ids.Count == 0) return new(); var rows = await db.ClubMemberships .Where(m => ids.Contains(m.ClubId)) .GroupBy(m => m.ClubId) .Select(g => new { ClubId = g.Key, Count = g.Count() }) .ToListAsync(); return rows.ToDictionary(r => r.ClubId, r => r.Count); } /// Single club lookup by id. Returns null for missing /// or disbanded (State != 0) clubs so the controller surfaces 404 /// before any wire-shape projection runs. public Task GetByIdAsync(long clubId) => db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0); /// Single club lookup by exact display name. Used by /// GET /club?name=...; case-insensitive matching keeps the /// browse/name-resolve path stable across SQLite and Postgres /// collations. public Task GetByNameAsync(string name) { var trimmed = name.Trim().ToLower(); return db.Clubs.FirstOrDefaultAsync(c => c.State == 0 && c.Name.ToLower() == trimmed); } /// Single membership row lookup. Used by both the /// /club/{id}/members/{playerId} read and every member-mutation /// endpoint to verify the target row exists before acting. public Task MembershipForAsync(long clubId, long playerId) => db.ClubMemberships.FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == playerId); /// Bulk member-row lookup for members/bulk. Returns /// only rows that exist; missing playerIds are silently dropped (the /// watch handles a shorter list fine — it just renders no row). public Task> MembershipsBulkAsync(long clubId, IEnumerable playerIds) { var ids = playerIds.Distinct().ToList(); if (ids.Count == 0) return Task.FromResult(new List()); return db.ClubMemberships .Where(m => m.ClubId == clubId && ids.Contains(m.PlayerId)) .ToListAsync(); } /// List clubs an arbitrary player is a MEMBER of. Drives /// account/{playerId}/clubs on a profile card; uses the same /// State=0 filter as the my-clubs read so disbanded clubs stay /// hidden. public Task> ClubsForPlayerAsync(long playerId) => (from m in db.ClubMemberships join c in db.Clubs on m.ClubId equals c.Id where m.PlayerId == playerId && c.State == 0 orderby c.Name select c).ToListAsync(); /// /// 2020.12 PPGPAHNMGEC MembershipType wire enum verified /// against dist\RecRoom-2020.12.18-dump\DiffableCs\Assembly-CSharp\PPGPAHNMGEC.cs. /// These ARE the values the watch decodes — earlier 0..N guesses /// were wrong: MyMembershipType=4 would have rendered as /// "Pending_Denied" instead of "Creator" and disabled every owner /// affordance in the watch UI. /// public const int MembershipTypeBanned = -1; public const int MembershipTypeNotMember = 0; public const int MembershipTypeRequested = 1; // Pending_Requested public const int MembershipTypeInvited = 2; // Pending_Invited public const int MembershipTypeDenied = 3; // Pending_Denied public const int MembershipTypeMember = 10; public const int MembershipTypeModerator = 20; public const int MembershipTypeCoOwner = 30; public const int MembershipTypeOwner = 100; // Creator on the wire public static int MembershipTypeFromPerms(int perms) { // 256 = ban marker (set by /members/ban handler). if ((perms & 256) != 0) return MembershipTypeBanned; // 128 = pending (set by /members/invite + /requesttojoin handlers). // We don't yet distinguish invite-from vs request-to on the perms // int; surface Requested by default so the caller's "I asked to // join" UI lights up. (Outgoing invites from owners use the same // marker; the watch shows them under "pending invites" either // way because the deserializer only branches on // Member/Moderator/CoOwner/Creator vs Pending_*.) if ((perms & 128) != 0) return MembershipTypeRequested; if (perms >= 127) return MembershipTypeOwner; if (perms >= 124) return MembershipTypeCoOwner; if (perms >= 24) return MembershipTypeModerator; return MembershipTypeMember; } /// Inverse of for /// changetype. Returns the canonical perms int to assign /// when the caller sets a new role. Owner (Creator=100 on the wire) /// is intentionally excluded — promotion to Owner is a /// transfer-ownership flow, not a changetype call. public static int PermsFromMembershipType(int membershipType) => membershipType switch { MembershipTypeCoOwner => 124, MembershipTypeModerator => 24, MembershipTypeMember => 0, _ => 0, }; /// /// Persist a club-targeted moderation report. Stores into the same /// table the admin queue reads from, with /// carrying the clubId (we /// don't have a separate TargetClubId column yet — the admin SPA /// renders any non-zero target field on the report card, so this /// surfaces as a club report there too via the Message prefix). /// public async Task AddClubReportAsync(long clubId, long reporterId, int category, string message) { db.Reports.Add(new ReportEntity { ReporterPlayerId = reporterId, TargetPlayerId = 0, TargetRoomId = clubId, Category = category, Message = $"[club {clubId}] {message ?? string.Empty}", }); await db.SaveChangesAsync(); } /// /// Create a new club + the owner's membership row in one go. Per /// the 2020.12 wire club/create response shape, the caller /// projects the resulting into the /// PIHMJGCGNLP envelope via the controller's wire mappers. /// Returns null when the name collides with an existing club so /// the caller can surface a 409. /// public async Task CreateAsync(long creatorId, string name, string? description, string? imageName, string? category) { var trimmed = (name ?? string.Empty).Trim(); if (trimmed.Length == 0) return null; if (await db.Clubs.AnyAsync(c => c.Name == trimmed)) return null; var club = new ClubEntity { Name = trimmed, Description = description ?? string.Empty, ImageName = imageName ?? string.Empty, Category = category ?? string.Empty, CreatorPlayerId = creatorId, }; db.Clubs.Add(club); await db.SaveChangesAsync(); db.ClubMemberships.Add(new ClubMembershipEntity { ClubId = club.Id, PlayerId = creatorId, Permissions = 127, // Owner }); await db.SaveChangesAsync(); return club; } /// Apply a free-form mutation to a club row after /// verifying the caller is owner/co-owner. Returns the updated /// entity on success, null on missing club, or throws /// when the caller can't /// modify. Used by every club/{id}/modify* endpoint variant. public async Task ModifyAsync(long clubId, long callerId, Action mutator) { var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0); if (club is null) return null; if (!await CanManageAsync(clubId, callerId, club)) throw new UnauthorizedAccessException(); mutator(club); club.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return club; } /// /// Check whether the caller can perform admin-class mutations on a /// club (owner, co-owner, or moderator depending on the action). /// Owner always wins; co-owner and moderator are looked up via the /// membership perms int. Used by every club/{id}/modify* and /// member mutation route. /// public async Task CanManageAsync(long clubId, long callerId, ClubEntity? club = null) { club ??= await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId); if (club is null) return false; if (club.CreatorPlayerId == callerId) return true; var membership = await db.ClubMemberships.AsNoTracking() .FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == callerId); if (membership is null) return false; // Pending invites/requests (128) and ban markers (256) must NOT // grant moderation rights. Without this mask a pending invitee // with perms=128 evaluates `>= 24` true and inherits the ability // to ban/promote — full moderation hijack via "I'd like to join". var perms = membership.Permissions; if ((perms & 128) != 0 || (perms & 256) != 0) return false; return perms >= 24; } /// Upsert a membership row at a target role. Used by /// invite/request/accept/changetype/directJoin/etc. Returns the /// row for callers that need to inspect or surface it; for void /// endpoints the caller just discards the result. public async Task UpsertMembershipAsync(long clubId, long targetPid, int perms) { var row = await db.ClubMemberships .FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == targetPid); if (row is null) { row = new ClubMembershipEntity { ClubId = clubId, PlayerId = targetPid, Permissions = perms, }; db.ClubMemberships.Add(row); } else { row.Permissions = perms; } await db.SaveChangesAsync(); return row; } /// Remove a membership row entirely — used by leave, /// remove, ban (after stamping a separate ban row if/when we /// introduce one), declineinvite, denyrequest. Idempotent: missing /// row → no-op. public async Task RemoveMembershipAsync(long clubId, long targetPid) { var row = await db.ClubMemberships .FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == targetPid); if (row is null) return; db.ClubMemberships.Remove(row); await db.SaveChangesAsync(); } /// List the announcements for a single club in newest-first /// order. Drives announcements/club/{cid} when the watch /// renders the club's full announcement feed (not just the unread /// rollup the home tab uses). public Task> AnnouncementsForClubAsync(long clubId, int take = 50) => db.ClubAnnouncements .Where(a => a.ClubId == clubId) .OrderByDescending(a => a.CreatedAt) .Take(take) .ToListAsync(); /// Single announcement lookup by (clubId, announcementId). /// Returns null when either id is unknown so the controller can /// 404 cleanly. public Task AnnouncementAsync(long clubId, long announcementId) => db.ClubAnnouncements.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId); /// Create a club announcement. Returns the new row's id, or null /// when the caller may not manage the club. public async Task CreateAnnouncementAsync( long clubId, long callerId, string title, string body, string imageName) { if (!await CanManageAsync(clubId, callerId)) return null; var row = new ClubAnnouncementEntity { ClubId = clubId, AuthorPlayerId = callerId, Title = Truncate(title, 200), Body = Truncate(body, 4000), ImageName = Truncate(imageName, 256), }; db.ClubAnnouncements.Add(row); await db.SaveChangesAsync(); return row.Id; } /// Edit an existing announcement in place. Null arguments leave /// the corresponding field untouched. Returns false when the caller may /// not manage the club or the row is unknown. public async Task UpdateAnnouncementAsync( long clubId, long announcementId, long callerId, string? title, string? body, string? imageName) { if (!await CanManageAsync(clubId, callerId)) return false; var row = await db.ClubAnnouncements .FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId); if (row is null) return false; if (title is not null) row.Title = Truncate(title, 200); if (body is not null) row.Body = Truncate(body, 4000); if (imageName is not null) row.ImageName = Truncate(imageName, 256); row.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return true; } private static string Truncate(string? value, int max) { value = (value ?? string.Empty).Trim(); return value.Length <= max ? value : value[..max]; } /// Delete an announcement after verifying the caller can /// manage the club. Idempotent: missing row → no-op. public async Task DeleteAnnouncementAsync(long clubId, long announcementId, long callerId) { if (!await CanManageAsync(clubId, callerId)) return false; var row = await db.ClubAnnouncements .FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId); if (row is null) return true; // idempotent — caller treats as success db.ClubAnnouncements.Remove(row); await db.SaveChangesAsync(); return true; } /// /// Idempotent seed of the default category tags. Inserts the /// canonical Sports / Creative / Roleplay / Hangout / Education /// tags on first launch; re-running on a populated table bails /// without touching existing rows so admin edits are never /// clobbered. /// public async Task SeedDefaultsAsync() { if (await db.ClubCategoryTags.AnyAsync()) return; var defaults = new (string Name, int Order)[] { ("Sports", 0), ("Creative", 1), ("Roleplay", 2), ("Hangout", 3), ("Education", 4), }; foreach (var (name, order) in defaults) { db.ClubCategoryTags.Add(new ClubCategoryTagEntity { Name = name, OrderIndex = order, Active = true, }); } await db.SaveChangesAsync(); } }