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