using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Text.Json; using DorkNet.Server.Auth; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Clubs; /// /// clubs.localhost — the 2020.12 watch's club home tab and announcement /// inbox. Path-based routing per the host-filter strategy in /// Program.cs (HostFilteringMiddleware gates which subdomains reach /// us, individual controllers don't need [Host] filters). /// /// URL surface (verb + template) verified against /// Cpp2IL_ISIL/.../JDJGIBLMFKK.txt: /// GET /club/home/me /// GET /club/mine/member /// GET /club/mine/created /// GET /club/categoryTags /// GET /announcements/v2/mine/unread /// GET /announcements/v2/subscription/mine/unread /// POST /announcements/club/{clubId}/{announcementId}/read /// /// Wire types (deserialiser JSON keys, per ISIL): /// Club (PLILLKHMNDA): ClubId, Name, Description, /// MainImageName, State, CreatorAccountId, Category, /// Visibility, Joinability, AllowJuniors, MemberCount, /// IsRRO, ClubhouseRoomId, ClubType /// Announcement (NFEMLMAFFIP): AnnouncementId, /// CreatorAccountId, ClubId, Title, Body, ImageName, /// CreatedAt, Meta /// UnreadResponse (HPACLJHLHBG) — wraps per-club rows: /// { clubId, LastReadAnnouncementId, announcements: [Announcement] } /// CategoryTags plain List<String> (per /// JDJGIBLMFKK.GetPrimaryTags callback shape). /// [ApiController] public class ClubsController(ClubService clubs) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); [HttpGet("/club/home/me")] [Authorize] public async Task HomeMe() { var pid = Me; var home = await clubs.HomeClubAsync(pid); if (home is null) { // Watch tolerates an empty/default Club object on the home // tab — it shows the "no home club" empty state. Returning // 204 No Content would short-circuit the deserialiser // (Action`1<PLILLKHMNDA>) so we send a minimal // sentinel row with ClubId=0 instead; the watch's null-check // path keys off ClubId. return Ok(ToWireClub(EmptyClub(), 0)); } var memberCount = await clubs.MemberCountAsync(home.Id); return Ok(ToWireClub(home, memberCount)); } [HttpGet("/club/mine/member")] [Authorize] public async Task MineMember() { var pid = Me; var rows = await clubs.MyClubsAsync(pid); var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id)); return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id)))); } [HttpGet("/club/mine/created")] [Authorize] public async Task MineCreated() { var pid = Me; var rows = await clubs.CreatedByAsync(pid); var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id)); return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id)))); } /// GET /club/search — browse/search clubs by /// optional category and text query. The watch issues this while /// building the Clubs discover tab, e.g. /// ?sort=0&category=Creative&count=30, and expects /// a CAJHIEENJJD envelope: Clubs, /// TotalClubs, and ContinuationToken. [HttpGet("/club/search")] public async Task ClubSearch( [FromQuery] string? query, [FromQuery] string? search, [FromQuery] string? category, [FromQuery] int? sort, [FromQuery] int? count) { var result = await clubs.SearchAsync(query ?? search, category, sort ?? 0, count ?? 30); var counts = await clubs.MemberCountsAsync(result.Clubs.Select(r => r.Id)); return Ok(new { Clubs = result.Clubs.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))), TotalClubs = result.TotalClubs, ContinuationToken = string.Empty, }); } /// GET /club?name=Foo — direct name lookup used by /// the client when resolving a club by display name. Returns the /// single PLILLKHMNDA Club wire shape. [HttpGet("/club")] public async Task ClubByName([FromQuery] string? name) { if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" }); var club = await clubs.GetByNameAsync(name); if (club is null) return NotFound(); var members = await clubs.MemberCountAsync(club.Id); return Ok(ToWireClub(club, members)); } /// The 2020.12 watch deserialises this as /// List<String> (per JDJGIBLMFKK.GetPrimaryTags /// callback signature), so we project just the tag Name column. [HttpGet("/club/categoryTags")] public async Task CategoryTags() { var rows = await clubs.CategoryTagsAsync(); return Ok(rows.Select(t => t.Name)); } /// /// Unread direct-feed announcements grouped by club into /// HPACLJHLHBG wire rows ({ clubId, LastReadAnnouncementId, /// announcements }). One row per club that has at least one unread /// item — clubs with nothing new are omitted so the watch's bell /// badge counts correctly. /// [HttpGet("/announcements/v2/mine/unread")] [Authorize] public async Task UnreadMine() { var pid = Me; var rows = await clubs.UnreadDirectAsync(pid); return Ok(GroupByClub(rows)); } [HttpGet("/announcements/v2/subscription/mine/unread")] [Authorize] public async Task UnreadSubscription() { var pid = Me; var rows = await clubs.UnreadSubscriptionAsync(pid); return Ok(GroupByClub(rows)); } /// GET /subscription/mine/member — list of clubs the /// caller is subscribed to. Watch deserializes as /// List<LNIKPLKOBDK>; per the dump at /// LNIKPLKOBDK.txt:110-124 the keys are lowercase /// accountId, clubId, subscriberCount. One row /// per subscription. Empty array is legal — the watch just hides the /// "Subscribed Clubs" section when the list is empty. [HttpGet("/subscription/mine/member")] [Authorize] public async Task MySubscriptions() { var pid = Me; var rows = await clubs.MySubscriptionsAsync(pid); return Ok(rows); } /// /// GET /subscription/subscriberCount/{accountId} — bare int /// of how many subscribers an account has. Watch caller is /// JDJGIBLMFKK.PNFHJBIAPBM(int) at /// JDJGIBLMFKK.txt:5533,5774 with signature /// IPromise<Int32>. Each Rec Room account has an /// implicit personal "creator club" so we union both possible /// counts: /// * player→player subscriptions where TargetPlayerId == id, and /// * club subscriptions where ClubId == id (if a club happens to /// share the id). /// Anonymous-safe — used by profile cards the watch shows before /// the viewer is logged in to anyone they're not friends with. /// [HttpGet("/subscription/subscriberCount/{accountId:long}")] public async Task SubscriberCount(long accountId) { var playerSubs = await clubs.PlayerSubscriberCountAsync(accountId); var clubSubs = await clubs.ClubSubscriberCountAsync(accountId); return Ok(playerSubs + clubSubs); } /// /// POST endpoint the watch hits when the player taps "mark read". /// Idempotent — the upsert in the service quietly returns if a /// read-marker already exists. /// [HttpPost("/announcements/club/{clubId:long}/{announcementId:long}/read")] [Authorize] public async Task MarkRead(long clubId, long announcementId) { var pid = Me; await clubs.MarkAnnouncementReadAsync(pid, announcementId); return Ok(new { Read = true }); } // ── Single-club reads ───────────────────────────────────────────── /// GET /club/{id} — single Club lookup. Wire shape /// is PLILLKHMNDA (same as the lists). Used by the watch's /// link-resolver and the "club tile" widget on profile cards. [HttpGet("/club/{clubId:long}")] public async Task ClubById(long clubId) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); var members = await clubs.MemberCountAsync(clubId); return Ok(ToWireClub(club, members)); } /// GET /club/{id}/details — the central club page /// payload. Wire shape is PIHMJGCGNLP with required keys /// Club, CoownerPermissions, ModeratorPermissions, /// MemberPermissions, MyMembershipType per /// PIHMJGCGNLP.cs. Returning just the Club shape throws /// KeyNotFoundException on the deserializer. [HttpGet("/club/{clubId:long}/details")] public Task ClubDetails(long clubId) => BuildDetailsResponseAsync(clubId); /// /club/{id}/clubhouse — returns the same /// PIHMJGCGNLP shape as /details. The watch's clubhouse /// setter passes an optional roomId; clearing the clubhouse uses /// DELETE. We accept GET / PUT / DELETE so whichever verb the /// request-builder chose lands on the handler, then route the /// roomId into ClubhouseRoomId when present (set/clear) and reply /// with the refreshed details envelope. [HttpGet("/club/{clubId:long}/clubhouse")] [HttpPut("/club/{clubId:long}/clubhouse")] [HttpDelete("/club/{clubId:long}/clubhouse")] public async Task ClubClubhouse(long clubId, [FromQuery(Name = "roomId")] long? roomId) { if (HttpMethods.IsPut(Request.Method) || HttpMethods.IsDelete(Request.Method)) { try { var updated = await clubs.ModifyAsync(clubId, Me, c => c.ClubhouseRoomId = HttpMethods.IsDelete(Request.Method) ? null : roomId); if (updated is null) return NotFound(); } catch (UnauthorizedAccessException) { return Forbid(); } } return await BuildDetailsResponseAsync(clubId); } /// GET /club/{id}/permissions/{role} — bare int of /// the permission bitmask for a role on this club. Used by the /// watch's role-permissions screen. Returns 0 for unknown roles so /// the screen renders an empty checklist instead of erroring. [HttpGet("/club/{clubId:long}/permissions/{role:int}")] public async Task ClubPermissions(long clubId, int role) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); return Ok(PermissionsForRole(role)); } // ── Account / created lists ────────────────────────────────────── /// GET /account/{playerId}/clubs — list of Clubs /// the player is a member of. Wire shape is /// List<PLILLKHMNDA>. Anonymous-safe — used on profile /// cards. [HttpGet("/account/{playerId:long}/clubs")] public async Task AccountClubs(long playerId) { var rows = await clubs.ClubsForPlayerAsync(playerId); var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id)); return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id)))); } /// GET /club/account/{playerId}/created — list of /// Clubs the player created. Wire shape is /// List<PLILLKHMNDA>. Anonymous-safe. [HttpGet("/club/account/{playerId:long}/created")] public async Task ClubAccountCreated(long playerId) { var rows = await clubs.CreatedByAsync(playerId); var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id)); return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id)))); } // ── Club create / modify ───────────────────────────────────────── public sealed class CreateClubRequest { public string? Name { get; set; } public string? Description { get; set; } public string? MainImageName { get; set; } public string? Category { get; set; } } /// POST /club/create — create a new club + the /// owner's membership row. Returns the PIHMJGCGNLP envelope /// (same shape as /club/{id}/details) so the watch's /// post-create navigation can render the club page without a /// separate fetch. [HttpPost("/club/create")] [Authorize] public async Task ClubCreate() { var req = await ReadCreateClubRequestAsync(); var club = await clubs.CreateAsync(Me, req.Name ?? string.Empty, req.Description, req.MainImageName, req.Category); if (club is null) return Conflict(new { error = "club_name_taken_or_empty" }); return await BuildDetailsResponseAsync(club.Id); } public sealed class ModifyClubRequest { public string? Name { get; set; } public string? Description { get; set; } public string? Category { get; set; } public int? Visibility { get; set; } public int? Joinability { get; set; } public bool? AllowJuniors { get; set; } public long? ClubhouseRoomId { get; set; } } /// POST /club/{id}/modify + /modifydetails — /// the watch sends two different request DTOs to two URLs, but /// both mutate the same Club row and both expect the /// PIHMJGCGNLP response. Single handler covers both /// because the response shape is identical and we only persist /// fields the request includes. [HttpPost("/club/{clubId:long}/modify")] [HttpPut("/club/{clubId:long}/modify")] [HttpPost("/club/{clubId:long}/modifydetails")] [HttpPut("/club/{clubId:long}/modifydetails")] [Authorize] public async Task ClubModify(long clubId, [FromBody] ModifyClubRequest req) { try { var updated = await clubs.ModifyAsync(clubId, Me, c => { if (!string.IsNullOrWhiteSpace(req.Name)) c.Name = req.Name.Trim(); if (req.Description is not null) c.Description = req.Description; if (req.Category is not null) c.Category = req.Category; if (req.Visibility is int v) c.Visibility = v; if (req.Joinability is int j) c.Joinability = j; if (req.AllowJuniors is bool aj) c.AllowJuniors = aj; if (req.ClubhouseRoomId is long ch) c.ClubhouseRoomId = ch; }); if (updated is null) return NotFound(); return await BuildDetailsResponseAsync(updated.Id); } catch (UnauthorizedAccessException) { return Forbid(); } } public sealed class MainImageRequest { public string? ImageName { get; set; } } /// POST /club/{id}/mainimage — replace the club's /// MainImageName. Same PIHMJGCGNLP response so the watch can /// refresh the club page in one round-trip. [HttpPost("/club/{clubId:long}/mainimage")] [HttpPut("/club/{clubId:long}/mainimage")] [Authorize] public async Task ClubMainImage(long clubId, [FromBody] MainImageRequest req) { try { var updated = await clubs.ModifyAsync(clubId, Me, c => c.ImageName = req.ImageName ?? string.Empty); if (updated is null) return NotFound(); return await BuildDetailsResponseAsync(updated.Id); } catch (UnauthorizedAccessException) { return Forbid(); } } /// POST /club/{id}/additionalimage/{slot} — the /// watch's gallery-image slot setter. The 2020 ClubEntity doesn't /// have additional image slots persisted yet, so we just return /// the current details unchanged (the gallery renders blank). /// Hooked up so the request doesn't 404 — populated once a future /// schema patch lands. [HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")] [HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")] [Authorize] public async Task ClubAdditionalImage(long clubId, int slot, [FromBody] MainImageRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); return await BuildDetailsResponseAsync(clubId); } // ── Single-member read ─────────────────────────────────────────── /// GET /club/{id}/members/{playerId} — single /// membership row. Wire shape is MFOAODGNGKB: AccountId, /// ClubId, MembershipType, CreatedAt. [HttpGet("/club/{clubId:long}/members/{playerId:long}")] public async Task ClubMember(long clubId, long playerId) { var row = await clubs.MembershipForAsync(clubId, playerId); if (row is null) return NotFound(); return Ok(ToWireMembership(row)); } // ── Member mutations (void / IPromise) ─────────────────────────── /// POST /club/{id}/members/requesttojoin — caller /// asks to join. For Open clubs we promote straight to Member; for /// RequestToJoin we mark Pending; InviteOnly clubs reject. [HttpPost("/club/{clubId:long}/members/requesttojoin")] [HttpPut("/club/{clubId:long}/members/requesttojoin")] [Authorize] public async Task MemberRequestToJoin(long clubId) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); var perms = club.Joinability switch { 0 => 0, // Open: instant member 1 => 128, // RequestToJoin: pending _ => -1, // InviteOnly: rejected }; if (perms == -1) return Forbid(); await clubs.UpsertMembershipAsync(clubId, Me, perms); return Ok(); } /// POST /club/{id}/members/directJoin — same as /// requesttojoin for an Open club but bypasses the pending flow /// even on RequestToJoin clubs (used by the "accept invite" flow /// after a successful invite acceptance). [HttpPost("/club/{clubId:long}/members/directJoin")] [Authorize] public async Task MemberDirectJoin(long clubId) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); await clubs.UpsertMembershipAsync(clubId, Me, 0); return Ok(); } /// POST /club/{id}/members/leave — caller leaves /// the club. Owner cannot leave without first transferring /// ownership (mirrors the GroupsController.Leave guard). [HttpPost("/club/{clubId:long}/members/leave")] [Authorize] public async Task MemberLeave(long clubId) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); if (club.CreatorPlayerId == Me) return BadRequest(new { error = "owner_must_transfer_first" }); await clubs.RemoveMembershipAsync(clubId, Me); return Ok(); } /// POST /club/{id}/members/invite — caller invites /// a target player. Pending=128 marker; the invited player's /// acceptinvite flips it to Member. public sealed class MemberInviteRequest { public int? PlayerId { get; set; } public int? AccountId { get; set; } public int? MembershipType { get; set; } } [HttpPost("/club/{clubId:long}/members/invite")] [HttpPut("/club/{clubId:long}/members/invite")] [Authorize] public async Task MemberInvite(long clubId, [FromBody] MemberInviteRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending return Ok(); } /// POST /club/{id}/members/invitemembers — bulk /// invite. Body is {playerIds: [...]} + optional /// membership-type override. public sealed class InviteMembersRequest { public List? PlayerIds { get; set; } public List? AccountIds { get; set; } public int? MembershipType { get; set; } } [HttpPost("/club/{clubId:long}/members/invitemembers")] [Authorize] public async Task MemberInviteMembers(long clubId, [FromBody] InviteMembersRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); foreach (var pid in ids) await clubs.UpsertMembershipAsync(clubId, pid, 128); return Ok(); } /// POST /club/{id}/members/acceptinvite — caller /// accepts an outstanding invite (their own pending row). [HttpPost("/club/{clubId:long}/members/acceptinvite")] [Authorize] public async Task MemberAcceptInvite(long clubId) { var row = await clubs.MembershipForAsync(clubId, Me); if (row is null) return NotFound(); if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" }); await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member return Ok(); } /// POST /club/{id}/members/declineinvite — caller /// rejects an outstanding invite (removes their pending row). [HttpPost("/club/{clubId:long}/members/declineinvite")] [Authorize] public async Task MemberDeclineInvite(long clubId) { await clubs.RemoveMembershipAsync(clubId, Me); return Ok(); } public sealed class MemberTargetRequest { public int? PlayerId { get; set; } public int? AccountId { get; set; } public int? MembershipType { get; set; } } /// POST /club/{id}/members/acceptrequest — owner / /// moderator approves a single pending join request, promoting the /// target's row from Pending to Member. [HttpPost("/club/{clubId:long}/members/acceptrequest")] [Authorize] public async Task MemberAcceptRequest(long clubId, [FromBody] MemberTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); var row = await clubs.MembershipForAsync(clubId, target); if (row is null) return NotFound(); if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" }); await clubs.UpsertMembershipAsync(clubId, target, 0); return Ok(); } public sealed class BulkTargetRequest { public List? PlayerIds { get; set; } public List? AccountIds { get; set; } } [HttpPost("/club/{clubId:long}/members/acceptrequests")] [Authorize] public async Task MemberAcceptRequests(long clubId, [FromBody] BulkTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); foreach (var pid in ids) { var row = await clubs.MembershipForAsync(clubId, pid); if (row is null || (row.Permissions & 128) == 0) continue; await clubs.UpsertMembershipAsync(clubId, pid, 0); } return Ok(); } [HttpPost("/club/{clubId:long}/members/denyrequest")] [Authorize] public async Task MemberDenyRequest(long clubId, [FromBody] MemberTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); await clubs.RemoveMembershipAsync(clubId, target); return Ok(); } [HttpPost("/club/{clubId:long}/members/denyrequests")] [Authorize] public async Task MemberDenyRequests(long clubId, [FromBody] BulkTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); foreach (var pid in ids) await clubs.RemoveMembershipAsync(clubId, pid); return Ok(); } [HttpPost("/club/{clubId:long}/members/remove")] [Authorize] public async Task MemberRemove(long clubId, [FromBody] MemberTargetRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); if (target == club.CreatorPlayerId) return BadRequest(new { error = "cannot_remove_owner" }); await clubs.RemoveMembershipAsync(clubId, target); return Ok(); } /// POST /club/{id}/members/ban — kick + prevent /// re-join. We persist with perms=256 as a ban marker bit so the /// request-to-join flow can refuse them later. [HttpPost("/club/{clubId:long}/members/ban")] [Authorize] public async Task MemberBan(long clubId, [FromBody] MemberTargetRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); if (target == club.CreatorPlayerId) return BadRequest(new { error = "cannot_ban_owner" }); await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker return Ok(); } [HttpPost("/club/{clubId:long}/members/unban")] [Authorize] public async Task MemberUnban(long clubId, [FromBody] MemberTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); await clubs.RemoveMembershipAsync(clubId, target); return Ok(); } /// POST /club/{id}/members/changetype — promote / /// demote a member's role. Body: PlayerId + MembershipType (the /// wire enum, NOT the perms int — we translate via /// ). [HttpPost("/club/{clubId:long}/members/changetype")] [HttpPut("/club/{clubId:long}/members/changetype")] [Authorize] public async Task MemberChangeType(long clubId, [FromBody] MemberTargetRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); if (club.CreatorPlayerId != Me) return Forbid(); // Owner-only — perms transitions are sensitive var target = req.PlayerId ?? req.AccountId ?? 0; var newType = req.MembershipType ?? ClubService.MembershipTypeMember; if (target == 0) return BadRequest(new { error = "missing_player_id" }); if (target == club.CreatorPlayerId) return BadRequest(new { error = "cannot_change_owner_role" }); await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType)); return Ok(); } // ── members/bulk + clubreporting ───────────────────────────────── /// GET /members/bulk?clubId=N&playerIds=A&playerIds=B /// — bulk membership lookup. Wire shape is /// List<JHMMGLNJHIB> with each entry carrying /// AccountId + MembershipType. [HttpGet("/members/bulk")] [HttpPost("/members/bulk")] public async Task MembersBulk() { if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId) && !long.TryParse(Request.Form["clubId"].ToString(), out clubId)) return BadRequest(new { error = "missing_club_id" }); var idsRaw = Request.Query["playerIds"].Concat(Request.Form["playerIds"]); var ids = idsRaw .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) .Where(n => n != 0) .ToList(); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await clubs.MembershipsBulkAsync(clubId, ids); return Ok(rows.Select(m => new { AccountId = (int)m.PlayerId, MembershipType = ClubService.MembershipTypeFromPerms(m.Permissions), })); } public sealed class ClubReportRequest { public long? ClubId { get; set; } public int? Category { get; set; } public string? Details { get; set; } } /// POST /api/clubreporting/v1/report — file a /// moderation report against a club. Persists to the same /// table /// the admin queue reads from; the controller prefixes the message /// with "[club {id}]" so admins can tell club reports apart from /// player reports without a schema change. [HttpPost("/api/clubreporting/v1/report")] [Authorize] public async Task ClubReport([FromBody] ClubReportRequest req) { if (req.ClubId is not long cid) return BadRequest(new { error = "missing_club_id" }); await clubs.AddClubReportAsync(cid, Me, req.Category ?? 0, req.Details ?? string.Empty); return Ok(); } // ── Announcements (per-club feed + per-announcement) ───────────── /// GET /announcements/club/{clubId} — full /// announcement feed for the club. Wire shape mirrors the unread /// rollup (HPACLJHLHBG envelope) but holds every visible /// announcement, newest first. [HttpGet("/announcements/club/{clubId:long}")] public async Task AnnouncementsForClub(long clubId) { var rows = await clubs.AnnouncementsForClubAsync(clubId); return Ok(GroupByClub(rows)); } /// GET /announcements/club/{clubId}/{aid} — single /// announcement read. Returns the inner Announcement wire object /// (NFEMLMAFFIP) without the per-club envelope. [HttpGet("/announcements/club/{clubId:long}/{announcementId:long}")] public async Task AnnouncementSingle(long clubId, long announcementId) { var row = await clubs.AnnouncementAsync(clubId, announcementId); if (row is null) return NotFound(); return Ok(ToWireAnnouncement(row)); } /// /announcements/club/{clubId}/{aid} delete — /// register DELETE, PUT, and POST so the watch's mutation verb /// reaches the same handler regardless of how its request-builder /// shaped the call. The service is idempotent on missing rows so /// double-fires are safe. [HttpDelete("/announcements/club/{clubId:long}/{announcementId:long}")] [HttpPost("/announcements/club/{clubId:long}/{announcementId:long}")] [HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")] [Authorize] public async Task AnnouncementDelete(long clubId, long announcementId) { if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid(); return Ok(); } // ── Wire-shape mappers ────────────────────────────────────────── /// /// Build the PIHMJGCGNLP envelope around a single club row. Used /// by /club/{id}/details, /clubhouse, and every modify* handler. /// MyMembershipType is derived from the caller's row (or /// NotAMember sentinel when the caller has no row). /// private async Task BuildDetailsResponseAsync(long clubId) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); var memberCount = await clubs.MemberCountAsync(clubId); var pid = ControllerBaseExtensions.CurrentPlayerId(User); var myType = ClubService.MembershipTypeNotMember; if (pid is long me) { var membership = await clubs.MembershipForAsync(clubId, me); if (membership is not null) myType = ClubService.MembershipTypeFromPerms(membership.Permissions); // Owner shortcut — the creator row may not exist (legacy data) but the // creator should always read as Owner. if (club.CreatorPlayerId == me) myType = ClubService.MembershipTypeOwner; } return Ok(new { // PIHMJGCGNLP deserializer at PIHMJGCGNLP.txt:143-194 reads // 7 fields off the response dict, not the 5 the parser-keys // doc lists. Missing any throws KeyNotFoundException — // CustomTags (List) and AdditionalImages // (List) need to be present even if empty. Club = ToWireClub(club, memberCount), CustomTags = Array.Empty(), AdditionalImages = Array.Empty(), CoownerPermissions = PermissionsForRole(ClubService.MembershipTypeCoOwner), ModeratorPermissions = PermissionsForRole(ClubService.MembershipTypeModerator), MemberPermissions = PermissionsForRole(ClubService.MembershipTypeMember), MyMembershipType = myType, }); } /// /// Bitmask of capabilities a given role has on a club. The bits /// are arbitrary (the wire enum JHEEFBMODPG is opaque in /// the readable dump) but stay consistent so the watch's /// role-permissions screen renders the same checklist on every /// refresh. Owner / Coowner can manage all; Moderator can manage /// content; Member can read. /// private static int PermissionsForRole(int membershipType) => membershipType switch { ClubService.MembershipTypeOwner => 0x7FFF, // everything ClubService.MembershipTypeCoOwner => 0x7FFE, // all except disband ClubService.MembershipTypeModerator => 0x00FF, // moderation only ClubService.MembershipTypeMember => 0x0007, // post + read + react _ => 0x0001, // read-only }; private static object ToWireMembership(ClubMembershipEntity row) => new { AccountId = (int)row.PlayerId, ClubId = row.ClubId, MembershipType = ClubService.MembershipTypeFromPerms(row.Permissions), CreatedAt = row.JoinedAt, }; private static ClubEntity EmptyClub() => new() { Id = 0, Name = string.Empty, Description = string.Empty, CreatorPlayerId = 0, ImageName = string.Empty, }; /// /// Project a to the 2020.12 Club wire /// shape. JSON keys + types match the /// PLILLKHMNDA.Deserialize body in ISIL exactly: /// ClubId/State/CreatorAccountId/Visibility/Joinability/MemberCount /// are ints, Name/Description/MainImageName/Category are strings, /// AllowJuniors/IsRRO are bools, ClubhouseRoomId is nullable long. /// private static object ToWireClub(ClubEntity c, int memberCount) => new { ClubId = c.Id, c.Name, c.Description, MainImageName = c.ImageName, State = c.State, CreatorAccountId = (int)c.CreatorPlayerId, Category = c.Category, Visibility = c.Visibility, Joinability = c.Joinability, AllowJuniors = c.AllowJuniors, MemberCount = memberCount, IsRRO = c.IsRRO, ClubhouseRoomId = c.ClubhouseRoomId, ClubType = c.ClubType, }; /// /// Project a to the 2020.12 /// Announcement wire shape. Keys + types per the /// NFEMLMAFFIP.Deserialize body in ISIL. /// private static object ToWireAnnouncement(ClubAnnouncementEntity a) => new { AnnouncementId = a.Id, CreatorAccountId = (int)a.AuthorPlayerId, ClubId = a.ClubId, a.Title, a.Body, a.ImageName, a.CreatedAt, Meta = string.Empty, }; private async Task ReadCreateClubRequestAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); return new CreateClubRequest { Name = FirstFormValue(form, "name", "Name"), Description = FirstFormValue(form, "description", "Description"), MainImageName = FirstFormValue(form, "mainImageName", "MainImageName", "imageName", "ImageName"), Category = FirstFormValue(form, "category", "Category"), }; } try { return await Request.ReadFromJsonAsync(JsonOptions) ?? new CreateClubRequest(); } catch (JsonException) { return new CreateClubRequest(); } } private static string? FirstFormValue(IFormCollection form, params string[] names) { foreach (var name in names) { var value = form[name].ToString(); if (!string.IsNullOrWhiteSpace(value)) return value; } return null; } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; /// /// Group a flat list of announcements into the per-club /// HPACLJHLHBG envelope shape. Uses /// for the outer object /// because the wire mixes lowercase and PascalCase keys for the /// same record (clubId + LastReadAnnouncementId + /// announcements) and anonymous types can't model that. /// private static List> GroupByClub( List rows) { return rows .GroupBy(a => a.ClubId) .Select(g => { var ordered = g.OrderByDescending(a => a.CreatedAt).ToList(); return new Dictionary { ["clubId"] = g.Key, ["LastReadAnnouncementId"] = (long?)null, ["announcements"] = ordered.Select(ToWireAnnouncement).ToList(), }; }) .ToList(); } }