1048 lines
54 KiB
C#
1048 lines
54 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Newtonsoft.Json;
|
|
using Campus_live_rewrite.api;
|
|
using Campus_live_rewrite.api.dbs;
|
|
using Campus_live_rewrite.api.server;
|
|
using Campus_live_rewrite.api.server.Classes;
|
|
|
|
namespace Campus_live_rewrite.Controllers
|
|
{
|
|
[Route("clubs")]
|
|
public class ClubsController : ControllerBase
|
|
{
|
|
// subscription/mine/member
|
|
// club/categoryTags // the tags for clubs // todo: get club tags from rec.net
|
|
// club/home/me // return 404 if you don't have a club home set, i think this return just "1" room id
|
|
// announcements/v2/mine/unread
|
|
// announcements/v2/subscription/mine/unread
|
|
// /clubs/club/mine/created
|
|
|
|
// Club category tags for the Clubs browse/filter UI AND the "Create a Club" screen's
|
|
// "Primary Club Tag" dropdown. An empty array left that dropdown empty, which disables
|
|
// the CREATE button (the client requires a tag). The fixed set matches the RecFlare
|
|
// reference (clubs.app.ts /club/categoryTags).
|
|
public static readonly string[] CategoryTags =
|
|
{ "Social", "Creative", "Competitive", "Casual", "Entertainment" };
|
|
|
|
[HttpGet("club/categoryTags")]
|
|
public ContentResult ClubCategoryTags() => new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(CategoryTags),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
|
|
// ── club DTO builders (match the RecFlare wire shapes) ──────────────────────────────
|
|
private static ContentResult ClubJson(object o, int status = 200) => new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(o),
|
|
ContentType = "application/json",
|
|
StatusCode = status
|
|
};
|
|
private static object ClubError(string message) => new { error = message, success = false, value = (object?)null };
|
|
|
|
private static object ClubDtoObj(ClubDB.Club c) => new
|
|
{
|
|
c.ClubId,
|
|
c.Name,
|
|
c.Description,
|
|
c.Category,
|
|
c.Visibility,
|
|
c.Joinability,
|
|
c.AllowJuniors,
|
|
c.MainImageName,
|
|
c.ClubType,
|
|
ClubhouseRoomId = c.ClubhouseRoomId,
|
|
CreatorAccountId = c.CreatorAccountId,
|
|
c.IsRRO,
|
|
c.MinLevel,
|
|
c.State,
|
|
MemberCount = ClubDB.MemberCount(c.ClubId),
|
|
};
|
|
|
|
private static object ClubPermObj(int clubId, int tier) => new
|
|
{
|
|
ClubId = clubId,
|
|
Type = tier,
|
|
ApproveMember = tier >= ClubDB.TierModerator,
|
|
BanUnban = tier >= ClubDB.TierModerator,
|
|
CreateEvent = tier >= ClubDB.TierCoOwner,
|
|
EditDetails = tier >= ClubDB.TierCoOwner,
|
|
EditPermissionSettings = tier >= ClubDB.TierCoOwner,
|
|
PostAnnouncement = tier >= ClubDB.TierModerator,
|
|
};
|
|
|
|
private static object ClubMemberObj(ClubDB.ClubMember m) => new
|
|
{
|
|
m.ClubMemberId,
|
|
m.ClubId,
|
|
AccountId = m.AccountId,
|
|
MembershipType = m.MembershipType,
|
|
CreatedAt = m.CreatedAt,
|
|
};
|
|
|
|
// A club gallery image as a whole SavedImage record (the client deserializes each entry
|
|
// into an object; a bare name fails its parser). Reads the real row when the name is a
|
|
// known image, else synthesizes a minimal record around the name.
|
|
private static object ClubImageObj(string name)
|
|
{
|
|
var img = ImageMetadataDB.GetImageByName(name);
|
|
if (img != null)
|
|
return new
|
|
{
|
|
Id = img.Id,
|
|
Type = (int)img.Type,
|
|
Accessibility = (int)img.Accessibility,
|
|
AccessibilityLocked = img.AccessibilityLocked,
|
|
ImageName = img.ImageName,
|
|
Description = img.Description,
|
|
PlayerId = img.PlayerId,
|
|
TaggedPlayerIds = img.TaggedPlayerIds ?? new List<ulong>(),
|
|
RoomId = (int?)img.RoomId,
|
|
PlayerEventId = (ulong?)img.PlayerEventId,
|
|
CreatedAt = img.CreatedAt,
|
|
CheerCount = img.CheerCount,
|
|
CommentCount = img.CommentCount,
|
|
};
|
|
return new
|
|
{
|
|
Id = 0,
|
|
Type = 3,
|
|
Accessibility = 1,
|
|
AccessibilityLocked = false,
|
|
ImageName = name,
|
|
Description = (string?)null,
|
|
PlayerId = 0UL,
|
|
TaggedPlayerIds = new List<ulong>(),
|
|
RoomId = (int?)null,
|
|
PlayerEventId = (ulong?)null,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CheerCount = 0,
|
|
CommentCount = 0,
|
|
};
|
|
}
|
|
|
|
private static object ClubDetailsObj(ClubDB.Club c, ulong viewer) => new
|
|
{
|
|
AdditionalImages = (c.AdditionalImageNames ?? new List<string>()).Select(ClubImageObj).ToList(),
|
|
Club = ClubDtoObj(c),
|
|
ClubId = c.ClubId,
|
|
CoownerPermissions = ClubPermObj(c.ClubId, ClubDB.TierCoOwner),
|
|
CustomTags = c.CustomTags ?? new List<string>(),
|
|
MemberPermissions = ClubPermObj(c.ClubId, ClubDB.TierMember),
|
|
ModeratorPermissions = ClubPermObj(c.ClubId, ClubDB.TierModerator),
|
|
MyMembershipType = ClubDB.MyMembership(c.ClubId, viewer),
|
|
};
|
|
|
|
// Accept an enum arriving as a name or an ordinal (the client sends either).
|
|
private static int ParseEnum(string? raw, Dictionary<string, int> names, int fallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return fallback;
|
|
raw = raw.Trim();
|
|
if (int.TryParse(raw, out var n)) return n;
|
|
foreach (var kv in names) if (string.Equals(kv.Key, raw, StringComparison.OrdinalIgnoreCase)) return kv.Value;
|
|
return fallback;
|
|
}
|
|
private static bool ParseBool(string? raw, bool fallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return fallback;
|
|
raw = raw.Trim();
|
|
if (bool.TryParse(raw, out var b)) return b;
|
|
return raw == "1" || raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// POST /clubs/club/create - the "Create a Club" screen. Form: name, description,
|
|
// category, and optionally visibility/joinability/allowJuniors/minLevel. Returns the
|
|
// { error, success, value } envelope carrying the new club's full details.
|
|
[HttpPost("club/create")]
|
|
public ContentResult CreateClub()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
string Field(string name) => Request.HasFormContentType ? Request.Form[name].ToString() : "";
|
|
|
|
string clubName = Field("name")?.Trim() ?? "";
|
|
string description = Field("description") ?? "";
|
|
if (string.IsNullOrWhiteSpace(clubName))
|
|
return ClubJson(ClubError("You must enter a name for your club."), 400);
|
|
if (clubName.Length > ClubDB.MaxNameLength)
|
|
return ClubJson(ClubError($"Club names can be at most {ClubDB.MaxNameLength} characters."), 400);
|
|
if ((description ?? "").Length > ClubDB.MaxDescriptionLength)
|
|
return ClubJson(ClubError($"Club descriptions can be at most {ClubDB.MaxDescriptionLength} characters."), 400);
|
|
|
|
int visibility = ParseEnum(Field("visibility"),
|
|
new() { ["Private"] = 0, ["Public"] = 1 }, 1);
|
|
int joinability = ParseEnum(Field("joinability"),
|
|
new() { ["Open"] = 0, ["InviteOnly"] = 1, ["AskToJoin"] = 2 }, 0);
|
|
bool allowJuniors = ParseBool(Field("allowJuniors"), true);
|
|
int minLevel = int.TryParse(Field("minLevel"), out var ml) ? ml : 0;
|
|
string category = Field("category")?.Trim() ?? "";
|
|
|
|
var club = ClubDB.CreateClub(account.Id, clubName, description, category,
|
|
visibility, joinability, allowJuniors, minLevel);
|
|
Console.WriteLine($"[clubs] '{club.Name}' (#{club.ClubId}) created by {account.Id}");
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, account.Id) });
|
|
}
|
|
|
|
// GET /clubs/club/{clubId} - the bare club DTO (404 if none). A creator auto-club id
|
|
// (5000000+) resolves too - the profile's subscription club 404'd here before.
|
|
[HttpGet("club/{clubId:int}")]
|
|
public ContentResult GetClub(int clubId)
|
|
{
|
|
var club = ClubDB.GetById(clubId) ?? (ClubDB.IsCreatorClub(clubId) ? ClubDB.CreatorClub(ClubDB.CreatorOfClub(clubId)) : null);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
return ClubJson(ClubDtoObj(club));
|
|
}
|
|
|
|
// ---- HOME CLUB (the club whose clubhouse the player spawns into) ----
|
|
// GET 404s when none is set - that is what the client expects for "no home club"; it
|
|
// errors on an empty object. PUT/DELETE answer the envelope.
|
|
[HttpGet("club/home/me")]
|
|
public ContentResult GetHomeClub()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
int id = ClubDB.GetHomeClubId(account.Id);
|
|
var club = id == 0 ? null : ClubDB.GetById(id);
|
|
if (club == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
|
|
return ClubJson(ClubDtoObj(club));
|
|
}
|
|
|
|
[HttpPut("club/home/me")]
|
|
[HttpPost("club/home/me")]
|
|
public ContentResult SetHomeClub()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
|
|
int clubId = (int)Campus_live_rewrite.api.server.RoomModeration.Long(f, "clubId", "ClubId", "id");
|
|
if (clubId <= 0) return ClubJson(new { error = "Invalid clubId.", success = false, value = (object?)null }, 400);
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return new ContentResult { Content = "", ContentType = "application/json", StatusCode = 404 };
|
|
if (ClubDB.MyMembership(clubId, account.Id) < 30 && club.CreatorAccountId != account.Id)
|
|
return ClubJson(new { error = "You are not a member of that club.", success = false, value = (object?)null }, 403);
|
|
ClubDB.SetHomeClub(account.Id, clubId);
|
|
return ClubJson(new { error = "", success = true, value = ClubDtoObj(club) });
|
|
}
|
|
|
|
[HttpDelete("club/home/me")]
|
|
public ContentResult ClearHomeClub()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
ClubDB.ClearHomeClub(account.Id);
|
|
return ClubJson(new { error = "", success = true, value = (object?)null });
|
|
}
|
|
|
|
// GET /clubs/account/{accountId}/clubs - the clubs an account belongs to or created
|
|
// (public ones only for someone else's profile). Bare array of club DTOs.
|
|
[HttpGet("account/{accountId:long}/clubs")]
|
|
[HttpGet("club/account/{accountId:long}/clubs")]
|
|
public ContentResult ClubsOfAccount(long accountId)
|
|
{
|
|
var (account, _) = AuthHelper.ValidateToken(HttpContext);
|
|
ulong who = (ulong)Math.Max(0, accountId);
|
|
var clubs = ClubDB.GetByMember(who).Concat(ClubDB.GetByCreator(who))
|
|
.GroupBy(c => c.ClubId).Select(g => g.First())
|
|
.Where(c => account?.Id == who || c.Visibility == 1 || ClubDB.MyMembership(c.ClubId, account?.Id ?? 0) >= 30)
|
|
.ToList();
|
|
return ClubJson(clubs.Select(ClubDtoObj).ToList());
|
|
}
|
|
|
|
// GET /clubs/club/{clubId}/details - the full club-screen payload (NOT enveloped).
|
|
[HttpGet("club/{clubId:int}/details")]
|
|
public ContentResult GetClubDetails(int clubId)
|
|
{
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
var (account, _) = AuthHelper.ValidateToken(HttpContext);
|
|
ulong viewer = account?.Id ?? 0;
|
|
return ClubJson(ClubDetailsObj(club, viewer));
|
|
}
|
|
|
|
// GET /clubs/club/{clubId}/members - the members list envelope (empty for unknown club).
|
|
[HttpGet("club/{clubId:int}/members")]
|
|
public ContentResult GetClubMembers(int clubId)
|
|
{
|
|
int? tier = int.TryParse(Request.Query["membershipType"].ToString(), out var t) ? t : (int?)null;
|
|
string? sortBy = Request.Query["sortBy"].ToString();
|
|
var members = ClubDB.GetMembers(clubId, tier, string.IsNullOrEmpty(sortBy) ? null : sortBy)
|
|
.Select(ClubMemberObj).ToList();
|
|
return ClubJson(new { error = "", success = true, value = members });
|
|
}
|
|
|
|
// PUT /clubs/club/{clubId}/modifydetails (and /modify) — edit a club (co-owner+). The
|
|
// create flow's step-4 (privacy / member-access / custom tags) PUTs here; it was missing,
|
|
// so it showed "Failed to Modify Club Details". Form fields (all optional, absent = keep):
|
|
// name, description, category, visibility, joinability, allowJuniors, minLevel, customTags.
|
|
// Answers the details envelope.
|
|
[HttpPut("club/{clubId:int}/modifydetails")]
|
|
[HttpPut("club/{clubId:int}/modify")]
|
|
public ContentResult ModifyClub(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
|
|
bool Has(string k) => Request.HasFormContentType && Request.Form.ContainsKey(k);
|
|
string F(string k) => Request.HasFormContentType ? Request.Form[k].ToString() : "";
|
|
|
|
string? name = Has("name") ? F("name") : null;
|
|
string? desc = Has("description") ? F("description") : null;
|
|
string? cat = Has("category") ? F("category") : null;
|
|
int? vis = Has("visibility") ? ParseEnum(F("visibility"), new() { ["Private"] = 0, ["Public"] = 1 }, club.Visibility) : (int?)null;
|
|
int? join = Has("joinability") ? ParseEnum(F("joinability"), new() { ["Open"] = 0, ["InviteOnly"] = 1, ["AskToJoin"] = 2 }, club.Joinability) : (int?)null;
|
|
bool? aj = Has("allowJuniors") ? ParseBool(F("allowJuniors"), club.AllowJuniors) : (bool?)null;
|
|
int? ml = (Has("minLevel") && int.TryParse(F("minLevel"), out var mlv)) ? mlv : (int?)null;
|
|
|
|
List<string>? tags = null;
|
|
if (Has("customTags"))
|
|
tags = Request.Form["customTags"]
|
|
.SelectMany(s => (s ?? "").Split(','))
|
|
.Select(t => t.Trim()).Where(t => t.Length > 0)
|
|
.Distinct().Take(3).ToList();
|
|
|
|
var updated = ClubDB.UpdateDetails(clubId, name, desc, cat, vis, join, aj, ml, tags);
|
|
Console.WriteLine($"[clubs] {account.Id} modified club {clubId}");
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
|
|
}
|
|
|
|
// ---- browse / profile ------------------------------------------------------------
|
|
//
|
|
// GET /clubs/club/search?sort=0&category=Social&count=6 - the Clubs page's rows, one
|
|
// request per category. This 404'd (46 times in the log), which is why nobody could see
|
|
// anyone's club: the browse page was empty. Reference: public, non-subscription clubs,
|
|
// { Clubs, ContinuationToken, TotalClubs }.
|
|
[HttpGet("club/search")]
|
|
public ContentResult SearchClubs([FromQuery] string? category, [FromQuery] string? query, [FromQuery] string? sort, [FromQuery] int count = 30)
|
|
{
|
|
if (count <= 0 || count > 100) count = 30;
|
|
var (page, total) = ClubDB.Search(category, query, sort, count);
|
|
return ClubJson(new
|
|
{
|
|
Clubs = page.Select(ClubDtoObj).ToList(),
|
|
ContinuationToken = (string?)null,
|
|
TotalClubs = total
|
|
});
|
|
}
|
|
|
|
// GET /clubs/club/account/{accountId}/created - the clubs card on a PROFILE (404'd). The
|
|
// public clubs that account created, as a bare array like club/mine/created; the owner
|
|
// looking at their own profile sees private ones too.
|
|
[HttpGet("club/account/{accountId:long}/created")]
|
|
public ContentResult ClubsCreatedBy(long accountId)
|
|
{
|
|
var (account, _) = AuthHelper.ValidateToken(HttpContext);
|
|
var clubs = (account != null && (long)account.Id == accountId)
|
|
? ClubDB.GetByCreator((ulong)accountId)
|
|
: ClubDB.PublicByCreator((ulong)accountId);
|
|
return ClubJson(clubs.Select(ClubDtoObj).ToList());
|
|
}
|
|
|
|
// GET /clubs/club/{clubId}/members/{membershipType} - one tier of the member list (the
|
|
// client asks /members/0 for the pending requests it can approve). Same envelope.
|
|
[HttpGet("club/{clubId:int}/members/{membershipType:int}")]
|
|
public ContentResult GetClubMembersOfTier(int clubId, int membershipType)
|
|
{
|
|
var members = ClubDB.GetMembers(clubId, membershipType == 0 ? ClubDB.TierPendingRequest : membershipType, null)
|
|
.Select(ClubMemberObj).ToList();
|
|
return ClubJson(new { error = "", success = true, value = members });
|
|
}
|
|
|
|
// ---- membership --------------------------------------------------------------------
|
|
private ContentResult DetailsEnvelope(int clubId, ulong viewer)
|
|
{
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, viewer) });
|
|
}
|
|
|
|
// PUT /clubs/club/{id}/members/requesttojoin - the JOIN button. Open club -> member,
|
|
// AskToJoin -> pending, InviteOnly -> refused. Answers the details envelope so the client
|
|
// reads its new MyMembershipType.
|
|
[HttpPut("club/{clubId:int}/members/requesttojoin")]
|
|
[HttpPost("club/{clubId:int}/members/requesttojoin")]
|
|
public ContentResult RequestToJoin(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
if (ClubDB.IsCreatorClub(clubId) || ClubDB.GetById(clubId) == null)
|
|
return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
int tier = ClubDB.RequestJoin(clubId, account.Id);
|
|
Console.WriteLine($"[clubs] {account.Id} asked to join club {clubId} -> tier {tier}");
|
|
return DetailsEnvelope(clubId, account.Id);
|
|
}
|
|
|
|
// POST /clubs/club/{id}/join and /leave - the bare-club forms of the same two actions.
|
|
[HttpPost("club/{clubId:int}/join")]
|
|
public ContentResult JoinClub(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
ClubDB.RequestJoin(clubId, account.Id);
|
|
return ClubJson(ClubDtoObj(ClubDB.GetById(clubId)!));
|
|
}
|
|
|
|
[HttpPost("club/{clubId:int}/leave")]
|
|
public ContentResult LeaveClubBare(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (club.CreatorAccountId == account.Id)
|
|
return ClubJson(ClubError("You created this club - delete it instead of leaving."), 403);
|
|
ClubDB.Leave(clubId, account.Id);
|
|
return ClubJson(ClubDtoObj(ClubDB.GetById(clubId)!));
|
|
}
|
|
|
|
// POST/PUT /clubs/club/{id}/members/leave - leave (or withdraw a request); details envelope.
|
|
[HttpPost("club/{clubId:int}/members/leave")]
|
|
[HttpPut("club/{clubId:int}/members/leave")]
|
|
public ContentResult LeaveClub(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (club.CreatorAccountId == account.Id)
|
|
return ClubJson(ClubError("You created this club - delete it instead of leaving."), 403);
|
|
ClubDB.Leave(clubId, account.Id);
|
|
Console.WriteLine($"[clubs] {account.Id} left club {clubId}");
|
|
return DetailsEnvelope(clubId, account.Id);
|
|
}
|
|
|
|
// PUT /clubs/club/{id}/members/invite - a co-owner adds / promotes / demotes / bans someone
|
|
// (form: accountId, membershipType; absent tier = Member). Also how a pending request is
|
|
// approved (membershipType=10) or denied (membershipType=0). Never the creator.
|
|
[HttpPut("club/{clubId:int}/members/invite")]
|
|
[HttpPost("club/{clubId:int}/members/invite")]
|
|
[HttpPut("club/{clubId:int}/members/modify")]
|
|
[HttpPost("club/{clubId:int}/members/modify")]
|
|
public ContentResult InviteMember(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
int me = ClubDB.MyMembership(clubId, account.Id);
|
|
if (me < ClubDB.TierModerator) return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
|
|
string F(string k) => Request.HasFormContentType ? Request.Form[k].ToString() : Request.Query[k].ToString();
|
|
if (!ulong.TryParse(F("accountId"), out var target) || target == 0)
|
|
return ClubJson(ClubError("Invalid accountId."), 400);
|
|
int tier = int.TryParse(F("membershipType"), out var t) ? t : ClubDB.TierMember;
|
|
// A moderator may approve/deny/ban members; only a co-owner hands out staff tiers.
|
|
if (tier >= ClubDB.TierModerator && me < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
if (target == club.CreatorAccountId)
|
|
return ClubJson(ClubError("The club's creator can't be changed."), 400);
|
|
if (!ClubDB.SetTier(clubId, target, tier))
|
|
return ClubJson(ClubError("Invalid membership type."), 400);
|
|
Console.WriteLine($"[clubs] {account.Id} set {target} to tier {tier} in club {clubId}");
|
|
return DetailsEnvelope(clubId, account.Id);
|
|
}
|
|
|
|
// DELETE /clubs/club/{id}/members/{accountId} - kick (moderator+, never the creator).
|
|
[HttpDelete("club/{clubId:int}/members/{accountId:long}")]
|
|
public ContentResult KickMember(int clubId, long accountId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierModerator) return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
if ((ulong)accountId == club.CreatorAccountId) return ClubJson(ClubError("The club's creator can't be removed."), 400);
|
|
ClubDB.SetTier(clubId, (ulong)accountId, ClubDB.TierNone);
|
|
return DetailsEnvelope(clubId, account.Id);
|
|
}
|
|
|
|
// PUT /clubs/club/{id}/clubhouse - point the club at its clubhouse room (form roomId; 0 clears).
|
|
[HttpPut("club/{clubId:int}/clubhouse")]
|
|
public ContentResult SetClubhouse(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner) return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
string raw = Request.HasFormContentType ? Request.Form["roomId"].ToString() : Request.Query["roomId"].ToString();
|
|
int? roomId = int.TryParse(raw, out var r) && r > 0 ? r : (int?)null;
|
|
var club = ClubDB.SetClubhouse(clubId, roomId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, account.Id) });
|
|
}
|
|
|
|
// PUT /clubs/club/{id}/minlevel - the club's minimum level (form minLevel).
|
|
[HttpPut("club/{clubId:int}/minlevel")]
|
|
public ContentResult SetMinLevel(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner) return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
string raw = Request.HasFormContentType ? Request.Form["minLevel"].ToString() : Request.Query["minLevel"].ToString();
|
|
int ml = int.TryParse(raw, out var v) ? Math.Max(0, v) : 0;
|
|
var updated = ClubDB.UpdateDetails(clubId, null, null, null, null, null, null, ml, null);
|
|
if (updated == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated, account.Id) });
|
|
}
|
|
|
|
// DELETE /clubs/club/{id} - the creator deletes the club (memberships and announcements go with it).
|
|
[HttpDelete("club/{clubId:int}")]
|
|
public ContentResult DeleteClub(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.IsCreatorClub(clubId) ? null : ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (club.CreatorAccountId != account.Id) return ClubJson(ClubError("Only the club's creator can delete it."), 403);
|
|
ClubDB.Delete(clubId);
|
|
Console.WriteLine($"[clubs] {account.Id} deleted club {clubId} ('{club.Name}')");
|
|
return ClubJson(new { error = "", success = true, value = (object?)null });
|
|
}
|
|
|
|
// GET /clubs/club/{clubId}/hasDisabledClubChat - nothing disables club chat yet.
|
|
[HttpGet("club/{clubId:int}/hasDisabledClubChat")]
|
|
public ContentResult ClubChatDisabled(int clubId) => ClubJson(false);
|
|
|
|
// viewer / tier: who is looking, so their own posts (and a co-owner's board) carry the
|
|
// edit / delete flags the client shows the buttons from.
|
|
private static object AnnouncementObj(ClubDB.ClubAnnouncement a, ulong viewer = 0, int viewerTier = 0)
|
|
{
|
|
bool mine = viewer != 0 && a.AccountId == viewer;
|
|
bool canEdit = mine || viewerTier >= ClubDB.TierCoOwner;
|
|
return new
|
|
{
|
|
a.AnnouncementId,
|
|
a.ClubId,
|
|
a.AccountId,
|
|
CreatorAccountId = a.AccountId,
|
|
PlayerId = a.AccountId,
|
|
Title = a.Title ?? "",
|
|
Body = a.Body ?? "",
|
|
ImageName = a.ImageName ?? "",
|
|
Meta = a.Meta ?? "",
|
|
CreatedAt = a.CreatedAt.ToUniversalTime().ToString("o"),
|
|
ModifiedAt = (a.ModifiedAt ?? a.CreatedAt).ToUniversalTime().ToString("o"),
|
|
IsMine = mine,
|
|
CanEdit = canEdit,
|
|
CanDelete = canEdit,
|
|
IsEditable = canEdit,
|
|
};
|
|
}
|
|
|
|
// GET /clubs/announcements/club/{clubId} - the noticeboard, newest first, in the
|
|
// reference envelope. Reading it marks everything read for the viewer (that is what
|
|
// clears the unread badge the two /v2/.../unread lists drive). Works for creator
|
|
// (subscription) clubs too - a creator's posts reach every subscriber this way.
|
|
[HttpGet("announcements/club/{clubId:int}")]
|
|
public ContentResult ClubAnnouncements(int clubId)
|
|
{
|
|
var (account, _) = AuthHelper.ValidateToken(HttpContext);
|
|
ulong viewer = account?.Id ?? 0;
|
|
var list = ClubDB.GetAnnouncements(clubId);
|
|
int lastRead = ClubDB.LastReadAnnouncementId(clubId, viewer);
|
|
if (list.Count > 0 && viewer != 0) ClubDB.MarkAnnouncementsRead(clubId, viewer, list[0].AnnouncementId);
|
|
return ClubJson(new
|
|
{
|
|
error = "",
|
|
success = true,
|
|
value = new
|
|
{
|
|
Announcements = list.Select(a => AnnouncementObj(a, viewer, ClubDB.MyMembership(clubId, viewer))).ToList(),
|
|
ClubId = clubId,
|
|
LastAnnouncementId = list.Count > 0 ? list[0].AnnouncementId : (int?)null,
|
|
LastReadAnnouncementId = lastRead,
|
|
}
|
|
});
|
|
}
|
|
|
|
// POST /clubs/announcements/club/{clubId} - post to the noticeboard. Form fields title /
|
|
// body / imageName / meta (case-insensitive). Co-owner or above; for a creator club that
|
|
// is the creator. The envelope's value is the new announcement's id (reference).
|
|
[HttpPost("announcements/club/{clubId:int}")]
|
|
public ContentResult PostClubAnnouncement(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(ClubError("No such club."), 404);
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
|
|
string? Field(string name)
|
|
{
|
|
if (!Request.HasFormContentType) return null;
|
|
foreach (var k in Request.Form.Keys)
|
|
if (string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) return Request.Form[k].ToString();
|
|
return null;
|
|
}
|
|
var a = ClubDB.PostAnnouncement(clubId, account.Id, Field("title"), Field("body"), Field("imageName"), Field("meta"));
|
|
Console.WriteLine($"[clubs] {account.Id} posted announcement {a.AnnouncementId} to club {clubId} ('{a.Title}')");
|
|
|
|
// Tell the members it's there: a creator club's subscribers, a regular club's members.
|
|
try
|
|
{
|
|
var members = ClubDB.GetMembers(clubId, null, null).Select(m => m.AccountId).Where(id => id != account.Id).Distinct().ToList();
|
|
foreach (var m in members)
|
|
Notifications.FireAndForget(Notifications.SendToPlayer(m, JsonConvert.SerializeObject(new WebsocketEvents.Reponse
|
|
{
|
|
Id = "ClubAnnouncementReceived",
|
|
Msg = new { ClubId = clubId, AnnouncementId = a.AnnouncementId, ClubName = club.Name, Title = a.Title }
|
|
})), "club-announcement");
|
|
}
|
|
catch (Exception ex) { Console.WriteLine($"[clubs] announcement push failed: {ex.Message}"); }
|
|
|
|
return ClubJson(new { error = "", success = true, value = a.AnnouncementId });
|
|
}
|
|
|
|
// GET /clubs/announcements/club/{clubId}/{announcementId} - one announcement (the client
|
|
// opens it on click). Was 404.
|
|
[HttpGet("announcements/club/{clubId:int}/{announcementId:int}")]
|
|
public ContentResult GetClubAnnouncement(int clubId, int announcementId)
|
|
{
|
|
var (account, _) = AuthHelper.ValidateToken(HttpContext);
|
|
var a = ClubDB.GetAnnouncements(clubId).FirstOrDefault(x => x.AnnouncementId == announcementId);
|
|
if (a == null) return ClubJson(ClubError("No such announcement."), 404);
|
|
return ClubJson(new { error = "", success = true, value = AnnouncementObj(a, account?.Id ?? 0, ClubDB.MyMembership(clubId, account?.Id ?? 0)) });
|
|
}
|
|
|
|
// PUT/POST /clubs/announcements/club/{clubId}/{announcementId} - edit (the poster, or a
|
|
// co-owner+ / the creator). Form fields title / body / imageName / meta; only the ones
|
|
// sent change. Was 404, so the edit button had nothing to save to.
|
|
[HttpPut("announcements/club/{clubId:int}/{announcementId:int}")]
|
|
[HttpPost("announcements/club/{clubId:int}/{announcementId:int}")]
|
|
[HttpPost("announcements/club/{clubId:int}/{announcementId:int}/edit")]
|
|
public ContentResult EditClubAnnouncement(int clubId, int announcementId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var existing = ClubDB.GetAnnouncements(clubId).FirstOrDefault(a => a.AnnouncementId == announcementId);
|
|
if (existing == null) return ClubJson(ClubError("No such announcement."), 404);
|
|
if (existing.AccountId != account.Id && ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner && !PlayerDB.CheckDevFlag(account.Id))
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
var f = Campus_live_rewrite.api.server.RoomModeration.Fields(Request);
|
|
string? title = Campus_live_rewrite.api.server.RoomModeration.Str(f, "title", "Title");
|
|
string? body = Campus_live_rewrite.api.server.RoomModeration.Str(f, "body", "Body", "text", "Text");
|
|
string? image = Campus_live_rewrite.api.server.RoomModeration.Str(f, "imageName", "ImageName");
|
|
string? meta = Campus_live_rewrite.api.server.RoomModeration.Str(f, "meta", "Meta");
|
|
var updated = ClubDB.EditAnnouncement(announcementId, title, body, image, meta);
|
|
if (updated == null) return ClubJson(ClubError("No such announcement."), 404);
|
|
Console.WriteLine($"[clubs] {account.Id} edited announcement {announcementId} in club {clubId}");
|
|
return ClubJson(new { error = "", success = true, value = AnnouncementObj(updated, account.Id, ClubDB.MyMembership(clubId, account.Id)) });
|
|
}
|
|
|
|
// DELETE /clubs/announcements/club/{clubId}/{announcementId} - co-owner+ (or the poster).
|
|
// POST /clubs/announcements/club/{clubId}/{announcementId}/read - the reader saw it (the
|
|
// unread badge). Idempotent; answers the envelope.
|
|
[HttpPost("announcements/club/{clubId:int}/{announcementId:int}/read")]
|
|
[HttpPut("announcements/club/{clubId:int}/{announcementId:int}/read")]
|
|
public ContentResult MarkAnnouncementRead(int clubId, int announcementId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
ClubDB.MarkAnnouncementsRead(clubId, account.Id, announcementId);
|
|
return ClubJson(new { error = "", success = true, value = (object?)null });
|
|
}
|
|
|
|
[HttpDelete("announcements/club/{clubId:int}/{announcementId:int}")]
|
|
public ContentResult DeleteClubAnnouncement(int clubId, int announcementId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
var existing = ClubDB.GetAnnouncements(clubId).FirstOrDefault(a => a.AnnouncementId == announcementId);
|
|
if (existing == null) return ClubJson(ClubError("No such announcement."), 404);
|
|
if (existing.AccountId != account.Id && ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
ClubDB.DeleteAnnouncement(announcementId);
|
|
return ClubJson(new { error = "", success = true, value = announcementId });
|
|
}
|
|
|
|
// Read the form field "imageName" (case-insensitive), or "".
|
|
private string FormImageName()
|
|
{
|
|
if (!Request.HasFormContentType) return "";
|
|
foreach (var k in Request.Form.Keys)
|
|
if (string.Equals(k, "imageName", StringComparison.OrdinalIgnoreCase))
|
|
return Request.Form[k].ToString().Trim();
|
|
return "";
|
|
}
|
|
|
|
// GET /clubs/club/{clubId}/mainimage - the reference 404s here but the client asks for
|
|
// it, so answer the details envelope (MainImageName rides on value.Club).
|
|
[HttpGet("club/{clubId:int}/mainimage")]
|
|
public ContentResult GetClubMainImage(int clubId)
|
|
{
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
var (account, _) = AuthHelper.ValidateToken(HttpContext);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(club, account?.Id ?? 0) });
|
|
}
|
|
|
|
// PUT /clubs/club/{clubId}/mainimage - set the club's main image from an uploaded
|
|
// image name (the "Take a club photo" step). Co-owner or above. Answers the details
|
|
// envelope. Was missing -> the create flow showed "Failed to Modify Club Image".
|
|
[HttpPut("club/{clubId:int}/mainimage")]
|
|
public ContentResult SetClubMainImage(int clubId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
|
|
string name = FormImageName();
|
|
if (string.IsNullOrEmpty(name)) return ClubJson(ClubError("imageName is required."), 400);
|
|
|
|
var updated = ClubDB.SetMainImage(clubId, name);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
|
|
}
|
|
|
|
// PUT/DELETE /clubs/club/{clubId}/additionalimage/{index} - one gallery image by 0-based
|
|
// position (PUT sets/appends; DELETE removes and shifts up). Co-owner or above.
|
|
[HttpPut("club/{clubId:int}/additionalimage/{index:int}")]
|
|
public ContentResult SetClubGalleryImage(int clubId, int index)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
|
|
string name = FormImageName();
|
|
if (string.IsNullOrEmpty(name)) return ClubJson(ClubError("imageName is required."), 400);
|
|
|
|
var updated = ClubDB.SetAdditionalImage(clubId, index, name);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
|
|
}
|
|
|
|
[HttpDelete("club/{clubId:int}/additionalimage/{index:int}")]
|
|
public ContentResult DeleteClubGalleryImage(int clubId, int index)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null) return error;
|
|
|
|
var club = ClubDB.GetById(clubId);
|
|
if (club == null) return ClubJson(new { error = "No such club.", success = false, value = (object?)null }, 404);
|
|
if (ClubDB.MyMembership(clubId, account.Id) < ClubDB.TierCoOwner)
|
|
return ClubJson(ClubError("Insufficient permissions."), 403);
|
|
|
|
var updated = ClubDB.RemoveAdditionalImage(clubId, index);
|
|
return ClubJson(new { error = "", success = true, value = ClubDetailsObj(updated!, account.Id) });
|
|
}
|
|
|
|
[HttpGet("club/mine/created")]
|
|
public ContentResult MyClubs()
|
|
{
|
|
// Signed-out -> [] (never error), as the reference does. Otherwise the caller's
|
|
// created clubs as a bare array of club DTOs.
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
var clubs = (error != null || account == null)
|
|
? new List<ClubDB.Club>()
|
|
: ClubDB.GetByCreator(account.Id);
|
|
return ClubJson(clubs.Select(ClubDtoObj).ToList());
|
|
}
|
|
|
|
// GetMyMembershipClubs (the client calls /clubs/club/mine/member). This MUST return
|
|
// [] with 200 - NEVER 404. The People/social panel loads this right after the
|
|
// relationship list, and the RecFlare note is explicit: "an error there breaks the
|
|
// screen." A 404 here threw inside the client's social load and blanked the WHOLE
|
|
// panel - friends list, player cards and profiles (own + others). Confirmed from the
|
|
// client's Player.log: HTTP 404 thrown inside the relationship-list callback, and this
|
|
// endpoint 404'd on every refresh right after /api/relationships/v2/get. No club system
|
|
// yet, so the list is empty. Deliberately NOT auth-gated (return [] even signed out, as
|
|
// the reference does) so it can never 401/404 the screen.
|
|
[HttpGet("club/mine/member")]
|
|
public ContentResult MyMemberClubs()
|
|
{
|
|
// Signed-out -> [] (never error/404 - this loads with the social panel). Otherwise
|
|
// the clubs the caller belongs to, as a bare array of club DTOs.
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
var clubs = (error != null || account == null)
|
|
? new List<ClubDB.Club>()
|
|
: ClubDB.GetByMember(account.Id);
|
|
return ClubJson(clubs.Select(ClubDtoObj).ToList());
|
|
}
|
|
|
|
// The client calls /clubs/subscription/mine/member (no auth). Empty array = no club
|
|
// subscription memberships; the client chokes on null and errors on a 404 - same
|
|
// screen-breaking class as club/mine/member above.
|
|
// The 2025 client's "who am I subscribed to" call (it no longer uses my/subscriptions).
|
|
// Answered [] until now, so the client believed it was subscribed to nobody - the button
|
|
// never showed "Subscribed" even after a successful subscribe. The shape is the creator
|
|
// auto-club object captured from live RecNet traffic (see the comment at the bottom of
|
|
// this file): one club per creator the caller subscribes to, clubType 1, creatorAccountId
|
|
// = the creator. Club ids are the same synthetic ones subscription/details hands out.
|
|
[HttpGet("subscription/mine/member")]
|
|
public ContentResult MySubscriptionMemberships()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
var clubs = new List<object>();
|
|
foreach (var creator in PlayerDB.GetSubscribedToIds(account.Id).Distinct())
|
|
{
|
|
var acc = PlayerDB.GetAccountById(creator);
|
|
if (acc == null) continue;
|
|
clubs.Add(new
|
|
{
|
|
clubId = CreatorClubId(creator),
|
|
name = acc.Username ?? creator.ToString(),
|
|
category = "CreatorAutoTag",
|
|
mainImageName = "DefaultClubImage2k.jpg",
|
|
description = (string?)null,
|
|
creatorAccountId = creator,
|
|
state = 0,
|
|
memberCount = CountSubscribers(creator),
|
|
isRRO = false,
|
|
allowJuniors = true,
|
|
minLevel = 0,
|
|
visibility = 1,
|
|
joinability = 0,
|
|
clubhouseRoomId = (long?)null,
|
|
clubType = 1,
|
|
createdAt = (PlayerDB.GetFullAccount(creator)?.player?.created_at ?? DateTime.UtcNow).ToString("o"),
|
|
clubChatEnabled = false
|
|
});
|
|
}
|
|
return new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(clubs),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
// The caller's regular clubs whose noticeboard has posts they haven't read.
|
|
[HttpGet("announcements/v2/mine/unread")]
|
|
public ContentResult UnreadAnnoucements()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
var clubs = ClubDB.GetByMember(account.Id)
|
|
.Where(c => ClubDB.HasUnreadAnnouncements(c.ClubId, account.Id))
|
|
.Select(ClubDtoObj).ToList();
|
|
return ClubJson(clubs);
|
|
}
|
|
|
|
// The creators the caller subscribes to whose noticeboard has unread posts - the same
|
|
// club shape /subscription/mine/member hands out.
|
|
[HttpGet("announcements/v2/subscription/mine/unread")]
|
|
public ContentResult UnreadAnnoucements2()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
var clubs = new List<object>();
|
|
foreach (var creator in PlayerDB.GetSubscribedToIds(account.Id).Distinct())
|
|
{
|
|
int clubId = (int)CreatorClubId(creator);
|
|
if (!ClubDB.HasUnreadAnnouncements(clubId, account.Id)) continue;
|
|
var club = ClubDB.CreatorClub(creator);
|
|
if (club != null) clubs.Add(ClubDtoObj(club));
|
|
}
|
|
return ClubJson(clubs);
|
|
}
|
|
|
|
|
|
// The profile panel asks for /api/subscriptions/v1/subscriberCount/{id}, which
|
|
// had no route at all and 404'd. Same number the clubs route below serves, just
|
|
// at the path the client actually calls (absolute route - not under /clubs).
|
|
[HttpGet("/api/subscriptions/v1/subscriberCount/{PlayerId}")]
|
|
public ContentResult SubscriberCountApi(ulong PlayerId) => SubscriberCount(PlayerId);
|
|
|
|
// Every account has a creator club on the real RecNet (research/RecNet API.postman:
|
|
// /subscription/details/:id -> {clubId: <nonzero>, accountId, subscriberCount}). The
|
|
// client enables its Subscribe button off that clubId; answering 0 (as this did, and as
|
|
// the reference still does) reads as "this player has no club" and the button never
|
|
// fires. The club id is synthetic and stable - derived from the account id - since no
|
|
// club record is needed for anything but this handshake.
|
|
public static long CreatorClubId(ulong accountId) => 5_000_000L + (long)accountId;
|
|
|
|
// The real count, from the subscription lists themselves rather than the cached
|
|
// sub_count (which drifted whenever a subscribe raced an unsubscribe).
|
|
private static int CountSubscribers(ulong accountId) => PlayerDB.GetSubscriberIds(accountId).Count;
|
|
|
|
[HttpGet("subscription/subscriberCount/{PlayerId}")]
|
|
public ContentResult SubscriberCount(ulong PlayerId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
return new ContentResult()
|
|
{
|
|
Content = CountSubscribers(PlayerId).ToString(),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
// Postman: GET /subscription/my/subscribers -> bare array of account ids.
|
|
[HttpGet("subscription/my/subscribers")]
|
|
public ContentResult MySubscribers()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
return new ContentResult()
|
|
{
|
|
Content = JsonConvert.SerializeObject(PlayerDB.GetSubscriberIds(account.Id)),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
[HttpPost("subscription/{PlayerId}")]
|
|
public async Task<ContentResult> SubscribeToPlayer(ulong PlayerId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
await Task.Run(() => PlayerDB.AddSubscription(account.Id, PlayerId));
|
|
Console.WriteLine($"[subscription] {account.Id} subscribed to {PlayerId}");
|
|
// Website tabs of both parties refresh (subscriber count / Subscribed state).
|
|
SiteNotifications.Notify(account.Id);
|
|
SiteNotifications.Notify(PlayerId);
|
|
try
|
|
{
|
|
var selfUpdate = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
|
|
{
|
|
Id = "CreatorClubSubscriptionUpdate",
|
|
Msg = new { creatorAccountId = PlayerId, clubId = CreatorClubId(PlayerId), membershipType = 10 }
|
|
});
|
|
var notifySelfTask = Notifications.SendToPlayer(account.Id, selfUpdate);
|
|
|
|
await Task.WhenAll(notifySelfTask);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Failed to send notification: {ex.Message}");
|
|
}
|
|
return new ContentResult()
|
|
{
|
|
// Postman: {value: <clubId>, success, errorId, error}
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
value = CreatorClubId(PlayerId),
|
|
success = true,
|
|
errorId = (string?)null,
|
|
error = (string?)null
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
[HttpDelete("subscription/{PlayerId}")]
|
|
public async Task<ContentResult> UnsubscribeToPlayer(ulong PlayerId)
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
|
|
await Task.Run(() => PlayerDB.RemoveSubscription(account.Id, PlayerId));
|
|
Console.WriteLine($"[subscription] {account.Id} unsubscribed from {PlayerId}");
|
|
SiteNotifications.Notify(account.Id);
|
|
SiteNotifications.Notify(PlayerId);
|
|
try
|
|
{
|
|
var selfUpdate = JsonConvert.SerializeObject(new WebsocketEvents.Reponse
|
|
{
|
|
Id = "CreatorClubSubscriptionUpdate",
|
|
Msg = new { creatorAccountId = PlayerId, clubId = CreatorClubId(PlayerId), membershipType = 0 }
|
|
});
|
|
var notifySelfTask = Notifications.SendToPlayer(account.Id, selfUpdate);
|
|
|
|
await Task.WhenAll(notifySelfTask);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"Failed to send notification: {ex.Message}");
|
|
}
|
|
return new ContentResult()
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
success = true,
|
|
errorId = (string?)null,
|
|
error = (string?)null
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
[HttpGet("subscription/my/subscriptions")]
|
|
public ContentResult MySubscriptions()
|
|
{
|
|
var (account, error) = AuthHelper.ValidateToken(HttpContext);
|
|
if (error != null)
|
|
return error;
|
|
var subs = PlayerDB.GetSubscribedToIds(account.Id);
|
|
return new ContentResult()
|
|
{
|
|
Content = JsonConvert.SerializeObject(subs),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
}
|
|
|
|
// The profile panel fetches /clubs/subscription/details/{accountId} - for the caller's
|
|
// OWN profile too. It was only documented as the comment below and 404'd, which broke
|
|
// the OWN profile specifically (other players' profiles rendered, your own didn't).
|
|
// RecFlare returns a zeroed object; there's no subscription-club system here, so clubId
|
|
// 0. Deliberately NOT auth-gated - a 404/401 here blanks the panel.
|
|
// https://clubs.rec.net/subscription/details/1503269750 returns { clubId, accountId, subscriberCount }
|
|
[HttpGet("subscription/details/{accountId}")]
|
|
public ContentResult SubscriptionDetails(ulong accountId) => new ContentResult
|
|
{
|
|
Content = JsonConvert.SerializeObject(new
|
|
{
|
|
clubId = CreatorClubId(accountId),
|
|
accountId = accountId,
|
|
subscriberCount = CountSubscribers(accountId)
|
|
}),
|
|
ContentType = "application/json",
|
|
StatusCode = 200
|
|
};
|
|
|
|
// https://clubs.rec.net/club/2065378865047881941 return
|
|
/*
|
|
{
|
|
"clubId": 2065378865047881941,
|
|
"name": "33c7nhjv7txs1dsitn4v92pb5",
|
|
"category": "CreatorAutoTag",
|
|
"mainImageName": "DefaultClubImage2k.jpg",
|
|
"description": null,
|
|
"creatorAccountId": 1503269750,
|
|
"state": 0,
|
|
"memberCount": 0,
|
|
"isRRO": false,
|
|
"allowJuniors": true,
|
|
"minLevel": 0,
|
|
"visibility": 1,
|
|
"joinability": 0,
|
|
"clubhouseRoomId": null,
|
|
"clubType": 1,
|
|
"createdAt": "2025-08-03T13:18:44.3295131Z",
|
|
"clubChatEnabled": false
|
|
}
|
|
*/
|
|
}
|
|
}
|