using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
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
/// PUT /club/home/me (2023, form clubId)
/// DELETE /club/home/me (2023)
/// GET /club/mine/member
/// GET /club/mine/created
/// GET /club/categoryTags
/// DELETE /club/{clubId} (2023, disband)
/// GET /club/{clubId}/hasDisabledClubChat (2023, bare bool)
/// DELETE /club/{clubId}/mainimage (2023)
/// GET /club/{clubId}/members (2023, bare array)
/// GET /club/{clubId}/members/banned (2023, bare array)
/// GET /club/{clubId}/members/requests (2023, bare array)
/// GET /club/{clubId}/members/search (2023, paged envelope)
/// GET /club/{clubId}/members/requests/search (2023, paged envelope)
/// GET /announcements/mine (2023, flat array)
/// GET /announcements/subscription/mine (2023, flat array)
/// GET /announcements/v2/mine/unread
/// GET /announcements/v2/subscription/mine/unread
/// POST /announcements/club/{clubId}/{announcementId}/read
///
/// Still unserved for want of schema (see the per-route notes in
/// docs/recroom-2023-client-api-complete.md): PUT
/// club/{id}/minlevel, PUT club/{id}/clubChatEnabled, PUT
/// club/{id}/permissions/{role} and DELETE
/// club/{id}/additionalimage/{slot} — each needs storage that does
/// not exist yet (ClubEntity.MinLevel, ClubEntity.ClubChatEnabled, a
/// per-role club-permission table, a club additional-image table).
///
/// Wire types (deserialiser JSON keys, per ISIL):
/// Club (2020 PLILLKHMNDA / 2023 FOIJDINBPFG):
/// ClubId, Name, Description, MainImageName, State,
/// CreatorAccountId, Category, Visibility, Joinability,
/// AllowJuniors, MemberCount, MinLevel, IsRRO,
/// ClubChatEnabled, ClubhouseRoomId, ClubType
/// Announcement (NFEMLMAFFIP / JDPPAFLFNBD): AnnouncementId,
/// CreatorAccountId, ClubId, Title, Body, ImageName,
/// CreatedAt, Meta
/// UnreadResponse (EIKPFIDCKNE / NCHLBFPHFJE) — a LIST of
/// per-club rows { clubId, LastAnnouncementId,
/// LastReadAnnouncementId, announcements: [Announcement] }
/// ClubFeed (HPACLJHLHBG / FIAKMDGGIHH) — ONE such row,
/// not a list; see AnnouncementsForClub.
/// CategoryTags plain List<String> (per
/// JDJGIBLMFKK.GetPrimaryTags callback shape).
///
[ApiController]
public class ClubsController(ClubService clubs, DorkNetDbContext db) : 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));
}
/// PUT /club/home/me — "Set Home Clubhouse Room". The
/// 2023 client sends the target club in a form-urlencoded
/// clubId field (boxed Nullable<Int64> pushed through
/// BNDIAONDFFF.AFGEDDANEKP at IKMMOCKDKAF.txt:16923-16927)
/// — the issuing method is LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>).
/// Despite the untyped promise the body IS parsed: both branches hand
/// the response to BFDGEKKNAPB.PEFKBAEAOPD (:16858, :16943), the
/// {success,error} RecNetResult reader, so an empty 200 surfaced as
/// a failure (). Verb ordinal 3 (PUT) is the
/// HasValue branch of the same method at :16911; the null branch is the
/// DELETE below (:16838).
[HttpPut("/club/home/me")]
[Authorize]
public async Task HomeMeSet()
{
var pid = Me;
long clubId = 0;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "clubId", "ClubId" })
if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0)
{
clubId = fromForm;
break;
}
}
if (clubId == 0 && long.TryParse(Request.Query["clubId"].FirstOrDefault(), out var fromQuery))
clubId = fromQuery;
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
return RecNetResults.Ok();
}
/// DELETE /club/home/me — "Remove Home Clubhouse Room".
/// No request body; RecNetResult reply (IKMMOCKDKAF.txt:16830-16858: route
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
/// Clubhouse Room"). Idempotent so a double-tap is harmless.
[HttpDelete("/club/home/me")]
[Authorize]
public async Task HomeMeClear()
{
await clubs.ClearHomeClubAsync(Me);
return RecNetResults.Ok();
}
[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/mostactivetoday — the Clubs discover tab's
/// "most active today" carousel. Client contract is a bare
/// IReadOnlyList<Club> (not the search envelope), so this
/// returns a flat array of the canonical shape,
/// ordered by the club service's default activity sort.
[HttpGet("/club/mostactivetoday")]
public async Task MostActiveToday([FromQuery] int? count)
{
var result = await clubs.SearchAsync(null, null, sort: 0, count: Math.Clamp(count ?? 30, 1, 100));
var counts = await clubs.MemberCountsAsync(result.Clubs.Select(r => r.Id));
return Ok(result.Clubs.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))).ToList());
}
/// 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, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
}
/// GET /announcements/mine — every announcement across the
/// clubs the caller is a member of, newest first. Distinct from the v2
/// unread rollup above in BOTH shape and semantics: the issuing method is
/// FGLDKEJLAKB<List<JDPPAFLFNBD>> IOCFHCFLIMJ()
/// (IKMMOCKDKAF.txt:996), i.e. a FLAT array of Announcement objects
/// with no per-club envelope and no unread filter — the request literals
/// at :1075 carry the description "get all announcements for clubs I'm
/// in".
[HttpGet("/announcements/mine")]
[Authorize]
public async Task AnnouncementsMine()
{
var rows = await clubs.AnnouncementsForMemberClubsAsync(Me);
return Ok(rows.Select(ToWireAnnouncement).ToList());
}
/// GET /announcements/subscription/mine — same flat
/// List<JDPPAFLFNBD> shape as
/// (IIOMOLFOLPD at
/// IKMMOCKDKAF.txt:1083, route literal at :1162) but scoped to the
/// clubs the caller SUBSCRIBES to rather than belongs to.
[HttpGet("/announcements/subscription/mine")]
[Authorize]
public async Task AnnouncementsSubscriptionMine()
{
var rows = await clubs.AnnouncementsForSubscribedClubsAsync(Me);
return Ok(rows.Select(ToWireAnnouncement).ToList());
}
[HttpGet("/announcements/v2/subscription/mine/unread")]
[Authorize]
public async Task UnreadSubscription()
{
var pid = Me;
var rows = await clubs.UnreadSubscriptionAsync(pid);
return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
}
/// 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);
}
/// GET /subscription/details/{accountId} — the creator's
/// subscription card. Both clients read the same three keys: the 2020 watch
/// via LNIKPLKOBDK (exact-match lowercase accountId,
/// clubId, subscriberCount) and 2023 via DDFNNBDLBKF
/// (tri-cased AccountId/ClubId/SubscriberCount, DDFNNBDLBKF.txt:279-346)
/// — camelCase satisfies both, so the casing here is load-bearing for 2020
/// and must not be Pascal-ised.
///
/// clubId used to be missing entirely. The 2023 caller is literally
/// GetCreatorClubIdForSubscription, so the field defaulted to 0 and
/// profile→creator-club navigation went nowhere.
[HttpGet("/subscription/details/{accountId:long}")]
public async Task SubscriptionDetails(long accountId)
{
var viewerId = this.CurrentPlayerId();
var subscriberCount = await clubs.PlayerSubscriberCountAsync(accountId);
var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId);
var isSubscribed = viewerId is long viewer
&& await clubs.IsSubscribedToPlayerAsync(viewer, accountId);
// A creator's "creator club" is the club they own; oldest created wins
// when an account somehow owns several.
var created = await clubs.CreatedByAsync(accountId);
var clubId = created.Count > 0 ? created.Min(c => c.Id) : 0L;
return Ok(new
{
accountId,
clubId,
subscriberCount,
subscribedCount,
isSubscribed,
});
}
///
/// 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 RecNetResults.Ok();
}
// ── 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));
}
/// DELETE /club/{id} — "Delete Club" (disband). Issuing
/// method is LDGADANDBIO MACDNDJNODA(Int64, String) — the string is
/// the club NAME used only for the client-side confirmation copy and is
/// never transmitted; the request is a bare DELETE with no body
/// (IKMMOCKDKAF.txt:17107-17120, verb ordinal 4, host 13, error
/// string "Failed to Delete Club"). The promise is built by
/// BFDGEKKNAPB.PEFKBAEAOPD (ExpandRecNetResult, :17137), so the
/// body is a {success,error} RecNetResult — an empty 200 made the
/// client report "Failed to Delete Club" after the club was gone. See
/// .
///
/// Owner-only, and a soft delete — see
/// .
[HttpDelete("/club/{clubId:long}")]
[Authorize]
public async Task ClubDelete(long clubId)
{
try
{
var disbanded = await clubs.DisbandAsync(clubId, Me);
if (disbanded is null) return NotFound();
return RecNetResults.Ok();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// GET /club/{id}/hasDisabledClubChat — the gate the club
/// chat flow checks before it will even request the club's thread. The
/// issuing method is FGLDKEJLAKB<System.Boolean> OFOJAHBKMOJ(Int64)
/// (IKMMOCKDKAF.txt:25422, route + verb ordinal 0 at :25540-25547),
/// so the body is a BARE JSON boolean — not an object, not a wrapper.
///
/// The answer is the negation of the Club wire field
/// ClubChatEnabled, which is now a real column set by
/// PUT club/{id}/clubChatEnabled.
[HttpGet("/club/{clubId:long}/hasDisabledClubChat")]
public async Task ClubHasDisabledChat(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
return Content(club.ClubChatEnabled ? "false" : "true", "application/json");
}
/// PUT /club/{id}/clubChatEnabled — toggle the club's chat
/// channel (IKMMOCKDKAF.txt:25779). Form field
/// clubChatEnabled; responds with the refreshed details
/// envelope.
[HttpPut("/club/{clubId:long}/clubChatEnabled")]
[HttpPost("/club/{clubId:long}/clubChatEnabled")]
[Authorize]
public async Task SetClubChatEnabled(long clubId)
{
var value = await ReadBoolFieldAsync("clubChatEnabled", "ClubChatEnabled", "enabled");
if (value is not bool enabled) return BadRequest(new { error = "missing_value" });
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c => c.ClubChatEnabled = enabled);
if (updated is null) return NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
return await BuildDetailsResponseAsync(clubId);
}
/// PUT /club/{id}/minlevel — minimum account level required
/// to join (IKMMOCKDKAF.txt:16561). Form field minLevel;
/// responds with the refreshed details envelope.
[HttpPut("/club/{clubId:long}/minlevel")]
[HttpPost("/club/{clubId:long}/minlevel")]
[Authorize]
public async Task SetClubMinLevel(long clubId)
{
var raw = await ReadFieldAsync("minLevel", "MinLevel", "level");
if (!int.TryParse(raw, out var minLevel)) return BadRequest(new { error = "missing_value" });
minLevel = Math.Clamp(minLevel, 0, 100);
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c => c.MinLevel = minLevel);
if (updated is null) return NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
return await BuildDetailsResponseAsync(clubId);
}
/// First value present under any of the given names, from the
/// form, the query string, or a JSON body. Null means "not sent".
private async Task ReadFieldAsync(params string[] names)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var n in names)
if (form.TryGetValue(n, out var v) && v.Count > 0) return v.ToString();
}
foreach (var n in names)
if (Request.Query.TryGetValue(n, out var q) && q.Count > 0) return q.ToString();
if (!Request.HasFormContentType)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var n in names)
if (doc.RootElement.TryGetProperty(n, out var v) &&
v.ValueKind != JsonValueKind.Null) return v.ToString();
}
catch (JsonException) { /* no usable body */ }
}
return null;
}
private async Task ReadBoolFieldAsync(params string[] names)
{
var raw = await ReadFieldAsync(names);
if (raw is null) return null;
if (bool.TryParse(raw, out var b)) return b;
return raw == "1" ? true : raw == "0" ? false : null;
}
/// 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))
{
// The client sends roomId in the form-urlencoded BODY, not the query
// string. Reading only the query bound null, and the PUT branch then
// wrote that null straight through — so "set clubhouse" silently
// CLEARED the clubhouse instead of setting it.
if (roomId is null && Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "roomId", "RoomId" })
if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0)
{
roomId = fromForm;
break;
}
}
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} — the permission
/// record for one role on this club. This used to answer a bare int; both
/// clients model it as an OBJECT (2023 MMOCDPPONNG, 2020
/// JHEEFBMODPG — identical layout: long ClubId, membership-type
/// enum, six bools), so see . Neither
/// client actually issues the GET (both only PUT this template), but the
/// shapes now agree.
[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(await PermissionsForRoleAsync(clubId, role));
}
/// PUT /club/{id}/permissions/{role} — rewrite what one
/// role may do. Route and verb confirmed at
/// IKMMOCKDKAF.txt:17685 (route) and :17709
/// (Move rdx, 3 = PUT); the six form booleans are
/// editDetails, approveMember, createEvent,
/// postAnnouncement, editPermissionSettings and
/// banUnban (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173,
/// :189, :205, :221, :237, :253).
///
/// Upserts one row per (club, role). Any field the client omits keeps the
/// value it currently resolves to, so a partial write cannot silently
/// revoke a permission. Gated on EditPermissionSettings for the caller's
/// own role, which is what the client's UI gate checks.
[HttpPut("/club/{clubId:long}/permissions/{role:int}")]
[HttpPost("/club/{clubId:long}/permissions/{role:int}")]
[Authorize]
public async Task SetClubPermissions(long clubId, int role)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var current = await ResolveRolePermissionsAsync(clubId, role);
var row = await db.ClubRolePermissions
.FirstOrDefaultAsync(p => p.ClubId == clubId && p.MembershipType == role);
if (row is null)
{
row = new ClubRolePermissionEntity { ClubId = clubId, MembershipType = role };
db.ClubRolePermissions.Add(row);
}
row.EditDetails = await ReadBoolFieldAsync("editDetails", "EditDetails") ?? current.EditDetails;
row.ApproveMember = await ReadBoolFieldAsync("approveMember", "ApproveMember") ?? current.ApproveMember;
row.CreateEvent = await ReadBoolFieldAsync("createEvent", "CreateEvent") ?? current.CreateEvent;
row.PostAnnouncement = await ReadBoolFieldAsync("postAnnouncement", "PostAnnouncement") ?? current.PostAnnouncement;
row.EditPermissionSettings = await ReadBoolFieldAsync("editPermissionSettings", "EditPermissionSettings") ?? current.EditPermissionSettings;
row.BanUnban = await ReadBoolFieldAsync("banUnban", "BanUnban") ?? current.BanUnban;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return await BuildDetailsResponseAsync(clubId);
}
// ── 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; }
/// Repeated customTags form field sent by
/// club/{0}/modifydetails alongside visibility / joinability /
/// allowJuniors (IKMMOCKDKAF_NestedType_MAFIJOPDGHK.txt:139-185).
/// Null means "not supplied"; an empty list clears the club's tags.
/// Note the form path can only ever deliver null or a non-empty list —
/// FormOrJsonModelBinder.Convert collapses an all-blank repeated
/// field to null — so a client that clears every tag over
/// form-urlencoded leaves the existing set in place. Only the JSON path
/// can send an explicit [].
public List? CustomTags { 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, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] 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();
// Tags live in the club↔tag junction, not on the club row, so they
// are written after ModifyAsync has already vetted the caller.
if (req.CustomTags is not null) await ReplaceCustomTagsAsync(updated.Id, req.CustomTags);
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, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] 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(); }
}
/// DELETE /club/{id}/mainimage — "Remove Club Image".
/// One method, BLEDOCHHHJM(Int64, String imageName), drives both
/// verbs: IKMMOCKDKAF.txt:16010 tests the captured imageName and
/// branches to verb ordinal 4 with no body when it is null (:16019-16024,
/// description "Remove Club Image") or verb 3 with the form field when it
/// is not (:16046-16050). Both branches go through
/// IBAKMFKEEDJ, so the response is the full LCLFBBPEMIH
/// details envelope, NOT a status-only 200.
///
/// Registered as its own action rather than a third binding on
/// so the delete semantics are explicit —
/// aliasing an edit verb onto a destructive handler is the exact bug that
/// made PUT /announcements/club/{c}/{a} destroy posts.
[HttpDelete("/club/{clubId:long}/mainimage")]
[Authorize]
public async Task ClubMainImageDelete(long clubId)
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c => c.ImageName = string.Empty);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// POST/PUT /club/{id}/additionalimage/{slot} — the club
/// gallery's per-slot image setter (IKMMOCKDKAF.txt:16214).
///
/// The slot is persisted as a row
/// and read back by the details envelope's AdditionalImages
/// (HIKCHBLAMLP = {ImageName, Slot}), so the gallery renders what
/// the creator set. An empty imageName clears the slot, same as the
/// DELETE twin.
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
public async Task ClubAdditionalImage(long clubId, int slot, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var imageName = (req?.ImageName ?? string.Empty).Trim();
var row = await db.ClubAdditionalImages
.FirstOrDefaultAsync(i => i.ClubId == clubId && i.Slot == slot);
if (imageName.Length == 0)
{
if (row is not null) db.ClubAdditionalImages.Remove(row);
}
else if (row is null)
{
db.ClubAdditionalImages.Add(new ClubAdditionalImageEntity
{
ClubId = clubId,
Slot = slot,
ImageName = imageName.Length > 256 ? imageName[..256] : imageName,
});
}
else
{
row.ImageName = imageName.Length > 256 ? imageName[..256] : imageName;
row.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return await BuildDetailsResponseAsync(clubId);
}
/// DELETE /club/{id}/additionalimage/{slot} — clear one
/// gallery slot. Same permission gate as the setter; idempotent, so
/// clearing an empty slot is a success.
[HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
public async Task ClubAdditionalImageDelete(long clubId, int slot)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var row = await db.ClubAdditionalImages
.FirstOrDefaultAsync(i => i.ClubId == clubId && i.Slot == slot);
if (row is not null)
{
db.ClubAdditionalImages.Remove(row);
await db.SaveChangesAsync();
}
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 lists (roster / banned / requests, + their searches) ──
/// GET /club/{id}/members — the club page's member roster.
/// Issuing method is
/// FGLDKEJLAKB<List<CADEIMCFIIG>> GFOBOIIGDJF(Int64, GNLOJEONFIG?, MJOOBDNCHBO?, Int32?, Int32?)
/// (IKMMOCKDKAF.txt:19444) so the body is a bare JSON ARRAY of the
/// same membership row already serves — no paged
/// envelope. Verb ordinal 0 (GET) at :19746, query keys verbatim from
/// :19753/:19777/:19809/:19822: membershipType, sortBy,
/// skip, take.
///
/// membershipType is the MJOOBDNCHBO wire value, not the stored
/// perms int, so it is compared after
/// . With no filter the
/// roster is real members only — ban markers and pending rows have their
/// own endpoints below and must not leak into the member list.
[HttpGet("/club/{clubId:long}/members")]
public async Task ClubMembers(
[FromRoute] long clubId,
[FromQuery] int? membershipType,
[FromQuery] int? sortBy,
[FromQuery] int? skip,
[FromQuery] int? take)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var rows = await LoadMemberRowsAsync(clubId);
rows = membershipType is int wanted
? rows.Where(r => r.MembershipType == wanted).ToList()
: rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList();
return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0)
.Select(r => ToWireMembership(r.Row))
.ToList());
}
/// GET /club/{id}/members/banned — the banned list.
/// MBKDPCIOMDD(Int64, GNLOJEONFIG?, Int32?, Int32?) returns
/// List<ICPNBOOIDLI> (IKMMOCKDKAF.txt:18692): a bare
/// array whose rows carry only AccountId (int), ClubId
/// (long) and CreatedAt — there is no MembershipType field on
/// ICPNBOOIDLI (recnet-runtime-decomp/ICPNBOOIDLI.cs). GET at :18978,
/// query keys sortBy/skip/take at :18985/:19016/:19028.
///
/// Rows are the perms=256 ban markers stamped by
/// . Moderator-gated: the ban list is staff-only
/// information.
[HttpGet("/club/{clubId:long}/members/banned")]
[Authorize]
public async Task ClubMembersBanned(
[FromRoute] long clubId,
[FromQuery] int? sortBy,
[FromQuery] int? skip,
[FromQuery] int? take)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var rows = (await LoadMemberRowsAsync(clubId))
.Where(r => (r.Row.Permissions & 256) != 0)
.ToList();
return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0)
.Select(r => ToWireBan(r.Row))
.ToList());
}
/// GET /club/{id}/members/requests — pending join
/// requests / outstanding invites.
/// GBJMBMCGCDL(Int64, Int32?, Int32?) returns
/// List<AADIHDCMEDB> (IKMMOCKDKAF.txt:21086) — a bare
/// array, GET at :21325, only skip + take query keys
/// (:21338, :21350). Error copy "Failed to get pending club member
/// requests" at :21394 confirms the screen.
[HttpGet("/club/{clubId:long}/members/requests")]
[Authorize]
public async Task ClubMemberRequests(
[FromRoute] long clubId,
[FromQuery] int? skip,
[FromQuery] int? take)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var rows = PendingRows(await LoadMemberRowsAsync(clubId));
return Ok(Page(SortMembers(rows, 1), skip ?? 0, take ?? 0)
.Select(r => ToWireMemberRequest(r.Row))
.ToList());
}
/// GET /club/{id}/members/search — the member-list search
/// box. PGMGPEKCNJP returns MBMNHFAPFCJ
/// (IKMMOCKDKAF.txt:18108), a PAGED envelope —
/// List<CADEIMCFIIG> + Int32 total + String continuation
/// token (recnet-runtime-decomp/MBMNHFAPFCJ.cs) — not the bare array the
/// unsearched roster returns. GET at :18475; query keys are DOT-PREFIXED
/// and must be read off rather than bound
/// as properties: parameters.name (:18484),
/// parameters.type (:18492), parameters.sortBy (:18521),
/// parameters.maxCount (:18554), plus an unprefixed
/// continuationToken (:18563).
///
/// The envelope's own key names are the one thing the binary cannot give
/// up (they live in DataMember blobs in global-metadata.dat), so the
/// entity-named pair validated on the sibling club/search envelope
/// — Clubs/TotalClubs/ContinuationToken — is mirrored
/// here as Members/TotalMembers, with Results/
/// TotalResults alongside as the alternate spelling. Json.NET
/// ignores whichever pair the DTO doesn't declare.
[HttpGet("/club/{clubId:long}/members/search")]
public async Task ClubMembersSearch(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var name = QueryValue("parameters.name", "name");
var type = QueryInt("parameters.type", "type", "membershipType");
var sortBy = QueryInt("parameters.sortBy", "sortBy");
var maxCount = QueryInt("parameters.maxCount", "maxCount", "take");
var offset = QueryInt("continuationToken") ?? 0;
var rows = await LoadMemberRowsAsync(clubId);
rows = type is int wanted
? rows.Where(r => r.MembershipType == wanted).ToList()
: rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList();
rows = FilterByName(rows, name);
var ordered = SortMembers(rows, sortBy ?? 0).ToList();
var (page, nextToken) = Slice(ordered, offset, maxCount);
return Ok(PagedEnvelope("Members", "TotalMembers",
page.Select(r => ToWireMembership(r.Row)).ToList(), ordered.Count, nextToken));
}
/// GET /club/{id}/members/requests/search — search inside
/// the pending-requests screen. EDANLDCJECM returns
/// EDFOCLNECPM (IKMMOCKDKAF.txt:21448): the paged envelope
/// over List<AADIHDCMEDB>. GET at :21812; dot-prefixed query
/// keys parameters.name (:21821), parameters.sortBy
/// (:21829, the NAHBJCGNJKA enum — Default=0, RequestDate_Asc=1,
/// RequestDate_Desc=2, Username_Asc=3, Username_Desc=4),
/// parameters.maxCount (:21862), parameters.status (:21867,
/// MDFFODMAIGJ — Invited=0, Requested=1, Denied=2) and unprefixed
/// continuationToken (:21896).
///
/// RequestDate_Asc/Desc index the same JoinedAt column JoinDate_Asc/Desc
/// do on the roster sort, so the two enums share
/// . The status filter can only ever
/// match Requested — see .
[HttpGet("/club/{clubId:long}/members/requests/search")]
[Authorize]
public async Task ClubMemberRequestsSearch(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var name = QueryValue("parameters.name", "name");
var sortBy = QueryInt("parameters.sortBy", "sortBy");
var maxCount = QueryInt("parameters.maxCount", "maxCount", "take");
var status = QueryInt("parameters.status", "status");
var offset = QueryInt("continuationToken") ?? 0;
var rows = FilterByName(PendingRows(await LoadMemberRowsAsync(clubId)), name);
if (status is int wantedStatus)
rows = rows.Where(r => RequestStatusFor(r.Row) == wantedStatus).ToList();
var ordered = SortMembers(rows, sortBy ?? 0).ToList();
var (page, nextToken) = Slice(ordered, offset, maxCount);
return Ok(PagedEnvelope("Requests", "TotalRequests",
page.Select(r => ToWireMemberRequest(r.Row)).ToList(), ordered.Count, nextToken));
}
/// A membership row paired with the account's username, which
/// every list endpoint above needs for the Username_Asc/Desc sorts and
/// the parameters.name filter. Loaded once per request rather than
/// per row.
private sealed record MemberRow(ClubMembershipEntity Row, string Username)
{
public int MembershipType => ClubService.MembershipTypeFromPerms(Row.Permissions);
}
private async Task> LoadMemberRowsAsync(long clubId)
{
var rows = await db.ClubMemberships.AsNoTracking()
.Where(m => m.ClubId == clubId)
.ToListAsync();
if (rows.Count == 0) return new List();
var ids = rows.Select(r => r.PlayerId).Distinct().ToList();
var names = await db.Players.AsNoTracking()
.Where(p => ids.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToListAsync();
var byId = names.ToDictionary(
n => n.Id,
n => string.IsNullOrWhiteSpace(n.Username) ? n.DisplayName : n.Username);
return rows
.Select(r => new MemberRow(r, byId.GetValueOrDefault(r.PlayerId, string.Empty)))
.ToList();
}
/// Rows carrying the pending marker (128) and NOT the ban marker
/// — the join-request / invite queue.
private static List PendingRows(List rows) => rows
.Where(r => (r.Row.Permissions & 128) != 0 && (r.Row.Permissions & 256) == 0)
.ToList();
private static List FilterByName(List rows, string? name)
{
if (string.IsNullOrWhiteSpace(name)) return rows;
var needle = name.Trim();
return rows
.Where(r => r.Username.Contains(needle, StringComparison.OrdinalIgnoreCase))
.ToList();
}
/// Shared ordering for GNLOJEONFIG (roster/banned) and
/// NAHBJCGNJKA (requests) — both enums are Default=0, date asc=1,
/// date desc=2, Username_Asc=3, Username_Desc=4 over the same JoinedAt
/// column (recnet-runtime-decomp/GNLOJEONFIG.cs, NAHBJCGNJKA.cs).
/// Default puts the most privileged roles first, which is the order the
/// club page renders its roster in.
private static IEnumerable SortMembers(IEnumerable rows, int sortBy) => sortBy switch
{
1 => rows.OrderBy(r => r.Row.JoinedAt).ThenBy(r => r.Row.Id),
2 => rows.OrderByDescending(r => r.Row.JoinedAt).ThenByDescending(r => r.Row.Id),
3 => rows.OrderBy(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id),
4 => rows.OrderByDescending(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id),
_ => rows.OrderByDescending(r => r.MembershipType)
.ThenBy(r => r.Row.JoinedAt)
.ThenBy(r => r.Row.Id),
};
/// skip/take paging for the three bare-array lists. take=0 means
/// "unspecified" (the client omits the param), so it falls back to a
/// 100-row page rather than returning nothing.
private static List Page(IEnumerable rows, int skip, int take) => rows
.Skip(Math.Max(skip, 0))
.Take(take <= 0 ? 100 : Math.Clamp(take, 1, 500))
.ToList();
/// Continuation-token paging for the two search envelopes. The
/// token is opaque to the client (a String on the DTO), so it carries the
/// next row offset; an empty string means "no more pages", which is what
/// the sibling club/search envelope already returns.
private static (List Page, string NextToken) Slice(
List ordered, int offset, int? maxCount)
{
var start = Math.Clamp(offset, 0, ordered.Count);
var size = maxCount is int m && m > 0 ? Math.Clamp(m, 1, 500) : 30;
var page = ordered.Skip(start).Take(size).ToList();
var next = start + page.Count;
return (page, next < ordered.Count ? next.ToString() : string.Empty);
}
private static Dictionary PagedEnvelope(
string listKey, string totalKey, List