mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
fix: second pass over the 2023-client wire defects, plus a coverage harness
Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.
Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.
Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.
Notable findings from this pass:
- The club permissions envelope key is literally "Type", not "MembershipType"
as the audit's suggested fix guessed. Read off the generated reader, then
cross-checked against the 2020 decompile — the int bitmask we were emitting
was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
{ Status, InventoryItem }; the scalar operation enum belongs in
BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
which exposes exactly those two properties. A test asserting the old scalar
shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
(PlayerId, Key) is unique, so a second purchase of the same boost threw
"UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
Now an upsert.
Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
@@ -11,8 +12,22 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
|
||||
/// player-scheduled gatherings.
|
||||
/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and
|
||||
/// broadcast for player-scheduled gatherings.
|
||||
///
|
||||
/// The 2023-03-21 client's event API surface is <c>RecNet.Runtime/CBKANFIOBCF</c>
|
||||
/// (route literals at <c>CBKANFIOBCF.txt:161-4156</c>). Three wire types drive
|
||||
/// nearly every handler here:
|
||||
/// * <b>HPIOAGDJHDH</b> (PlayerEvent) — 17 keys, formatter
|
||||
/// <c>HPMMKFGDAEC.txt:1139-1518</c>.
|
||||
/// * <b>MDCBEPJCJPO</b> (PlayerEvent + <c>Tags:[{Tag,Type}]</c>) — formatter
|
||||
/// <c>CENEMCMGDKG.txt:1191-1586</c>, tag entry keys
|
||||
/// <c>PCMLHLIBLNJ.txt:191-218</c>.
|
||||
/// * <b>PHHAKLPGNGC</b> (<c>{PlayerEvent,Result,TagModifyResult}</c>) —
|
||||
/// formatter <c>EEAAGIJHLOJ.txt:267-326</c>, <c>TagModifyResult</c> keys
|
||||
/// <c>ALJPHDEAHBK.txt:191-218</c>. Every v2 create/edit/delete/field
|
||||
/// mutation deserialises this wrapper, so a flat event body leaves the
|
||||
/// client's <c>PlayerEvent</c> null after a save.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -22,14 +37,23 @@ public class PlayerEventsController(
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Browse (v2) ──────────────────────────────────────────────────────
|
||||
/// <summary>PlayerEventResponseType.NotGoing — the one RSVP state that
|
||||
/// does not count towards AttendeeCount.</summary>
|
||||
private const int NotGoingResponse = 2;
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
|
||||
/// Returns the next 50 events ordered by start time. Wire shape
|
||||
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
|
||||
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> (and <c>v1</c>) — upcoming
|
||||
/// events list, a BARE JSON array of PlayerEvent.
|
||||
///
|
||||
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
|
||||
/// returns <c>List<HPIOAGDJHDH></c> and dispatches with verb 0
|
||||
/// (<c>CBKANFIOBCF.txt:1305</c>, route literal <c>:1304</c>). Only
|
||||
/// <c>[HttpPost]</c> was registered on <c>api/playerevents/v1</c>, so the
|
||||
/// whole tab 405'd.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2")]
|
||||
[HttpGet("api/playerevents/v1")]
|
||||
public async Task<ActionResult> ListUpcoming(
|
||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
@@ -41,9 +65,17 @@ public class PlayerEventsController(
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1|v2/{id}</c>. The client calls this
|
||||
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
|
||||
/// <c>?includeDetails=</c> / <c>?clubId=</c> (HLJBCGMONEH / OAKKDBLNKLG →
|
||||
/// MDCBEPJCJPO, query keys at
|
||||
/// <c>CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99</c>). Both DTOs are the
|
||||
/// same object, the detail one just adds <c>Tags</c>, and the Utf8Json
|
||||
/// formatters skip unknown keys — so we always emit the superset and no
|
||||
/// branch is needed.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||
@@ -51,23 +83,28 @@ public class PlayerEventsController(
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
return Ok(ToWire(ev));
|
||||
return Ok(await ToWireOneAsync(ev));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/search</c>. Query keys are
|
||||
/// <c>query</c>, <c>sort</c> and <c>scheduleFilter</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126</c>); the latter
|
||||
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/search")]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> Search(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int? sort = null,
|
||||
[FromQuery] int? scheduleFilter = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow);
|
||||
|
||||
var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(e =>
|
||||
@@ -75,12 +112,70 @@ public class PlayerEventsController(
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderBy(e => e.StartsAt)
|
||||
var result = await ApplySort(rows, sort)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(result));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/searchlive</c> — "happening now".
|
||||
/// Separate handler from <see cref="Search"/> because the response element
|
||||
/// type differs: FIKHCEPDIBF returns <c>List<CLDDIKOJMAM></c>, which
|
||||
/// is the PlayerEvent object plus <c>PlayerCount</c> (Int32) and
|
||||
/// <c>IsFull</c> (Boolean) — formatter <c>PDIFGFKAMBG.txt:1267,1294</c>.
|
||||
/// Head-count comes from the room's live GameSessions rows (their RoomId is
|
||||
/// the numeric room id as a string, see
|
||||
/// <c>Services/GameSessionService.cs:91-96</c>).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> SearchLive(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
live = live.Where(e =>
|
||||
e.Title.ToLower().Contains(needle) ||
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync();
|
||||
var wire = await ToWireManyAsync(rows);
|
||||
|
||||
var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList();
|
||||
var sessions = await db.GameSessions
|
||||
.Where(s => roomKeys.Contains(s.RoomId))
|
||||
.GroupBy(s => s.RoomId)
|
||||
.Select(g => new
|
||||
{
|
||||
RoomId = g.Key,
|
||||
Players = g.Sum(s => s.PlayerCount),
|
||||
Capacity = g.Sum(s => s.MaxCapacity),
|
||||
})
|
||||
.ToListAsync();
|
||||
var byRoom = sessions.ToDictionary(s => s.RoomId, s => s);
|
||||
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var players = 0;
|
||||
var capacity = 0;
|
||||
if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg))
|
||||
{
|
||||
players = agg.Players;
|
||||
capacity = agg.Capacity;
|
||||
}
|
||||
wire[i]["PlayerCount"] = players;
|
||||
wire[i]["IsFull"] = capacity > 0 && players >= capacity;
|
||||
}
|
||||
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -92,53 +187,53 @@ public class PlayerEventsController(
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/club/{clubId}</c>. PHNEDJAPIKI
|
||||
/// returns IOKLNPFOLGI — an OBJECT
|
||||
/// <c>{Events:[PlayerEvent],ContinuationToken:String}</c>
|
||||
/// (<c>GHBIGGJKPFI.txt:203-238</c>), not a bare array, and passes
|
||||
/// <c>take</c> / <c>continuationToken</c> query params
|
||||
/// (<c>CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89</c>). The token here is
|
||||
/// just the next skip offset; empty string means "no more pages" (the
|
||||
/// client's field is a plain String, so never emit null).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
|
||||
public async Task<ActionResult> ForClub(long clubId)
|
||||
public async Task<ActionResult> ForClub(
|
||||
long clubId,
|
||||
[FromQuery] int? take = null,
|
||||
[FromQuery] string? continuationToken = null)
|
||||
{
|
||||
var memberIds = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == clubId)
|
||||
.Select(m => m.PlayerId)
|
||||
.ToListAsync();
|
||||
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var pageSize = Math.Clamp(take ?? 50, 1, 100);
|
||||
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
// Fetch one extra row to find out whether another page exists.
|
||||
var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1);
|
||||
var hasMore = rows.Count > pageSize;
|
||||
if (hasMore) rows.RemoveAt(rows.Count - 1);
|
||||
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(new
|
||||
{
|
||||
Events = await ToWireManyAsync(rows),
|
||||
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/clubs</c> — events for a specific
|
||||
/// set of clubs. IEGLMOOPLED appends one repeated <c>id</c> query value per
|
||||
/// club (<c>CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59</c>); ignoring them
|
||||
/// returned every club's events and the watch could not bucket the list.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/clubs")]
|
||||
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var clubPlayerIds = await db.ClubMemberships
|
||||
.Select(m => m.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
var clubIds = new List<long>();
|
||||
foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value);
|
||||
var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take);
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
public sealed class EventBulkRequest
|
||||
@@ -148,6 +243,10 @@ public class PlayerEventsController(
|
||||
public List<long>? Ids { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/bulk</c> — verb 2 at
|
||||
/// <c>CBKANFIOBCF.txt:660</c>; the form field is <c>Ids</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64</c>). GET is kept for the
|
||||
/// 2020 client, which puts the ids in the query string.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
@@ -158,21 +257,29 @@ public class PlayerEventsController(
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/tagfilters</c> — AKCLLEJNFFD reads
|
||||
/// three String lists: <c>PinnedFilters</c>, <c>PopularFilters</c> and
|
||||
/// <c>TrendingFilters</c> (<c>HMDONHHKKGA.txt:279-346</c>). A missing key
|
||||
/// leaves the list null and the chips UI NREs when it enumerates.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/tagfilters")]
|
||||
public IActionResult TagFilters() => Ok(new
|
||||
{
|
||||
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
TrendingFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd events).
|
||||
/// PCELKKHNPHJ is <c>{Created:[PlayerEvent], Responses:[COAGAJPELCG]}</c>
|
||||
/// (<c>ENJDEONHNMO.txt:191-218</c>) where each response entry is
|
||||
/// <c>{PlayerEvent, PlayerEventResponse}</c> (<c>HJKLIIPPPEM.txt:215-258</c>)
|
||||
/// — a flat response row leaves <c>PlayerEvent</c> null and the My Events
|
||||
/// page NREs.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all")]
|
||||
public async Task<ActionResult> AllPlayerEvents()
|
||||
@@ -180,99 +287,477 @@ public class PlayerEventsController(
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
|
||||
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == me)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == me)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
return await AllForPlayerAsync(me);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
public Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
=> AllForPlayerAsync(accountId);
|
||||
|
||||
private async Task<ActionResult> AllForPlayerAsync(long playerId)
|
||||
{
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == accountId)
|
||||
.Where(e => e.CreatorPlayerId == playerId)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == accountId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.Where(r => r.PlayerId == playerId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
// Each response entry embeds its own event, which may not be one the
|
||||
// caller created — pull those in too so PlayerEvent is never null.
|
||||
var createdIds = created.Select(e => e.Id).ToHashSet();
|
||||
var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList();
|
||||
var extraEvents = new List<PlayerEventEntity>();
|
||||
if (missingIds.Count > 0)
|
||||
extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync();
|
||||
|
||||
var all = created.Concat(extraEvents).ToList();
|
||||
var wire = await ToWireMapAsync(all);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Created = created.Select(e => wire[e.Id]).ToList(),
|
||||
Responses = responses
|
||||
.Where(r => wire.ContainsKey(r.EventId))
|
||||
.Select(r => new
|
||||
{
|
||||
PlayerEvent = wire[r.EventId],
|
||||
PlayerEventResponse = ResponseWire(r),
|
||||
})
|
||||
.ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>Wire shape verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
|
||||
/// PascalCase keys <c>PlayerEventId, Name, Description,
|
||||
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
|
||||
/// ImageName, Accessibility</c>. Server-side we store these
|
||||
/// under different names (Title, StartsAt, EndsAt, Capacity) —
|
||||
/// remap explicitly here so the watch's
|
||||
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
|
||||
private static object ToWire(PlayerEventEntity ev) => new
|
||||
/// <summary>GET <c>api/playerevents/v1/{eventId}/responses</c> — the event
|
||||
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
|
||||
/// (<c>CBKANFIOBCF.txt:1699</c>, route literal <c>:1689</c>) and reads
|
||||
/// <c>List<JPIKCIGABBI></c>; the path was registered POST-only, so
|
||||
/// the list 405'd.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public async Task<ActionResult> ResponsesForEvent(long eventId)
|
||||
{
|
||||
PlayerEventId = ev.Id,
|
||||
Name = ev.Title,
|
||||
ev.Description,
|
||||
StartTime = ev.StartsAt,
|
||||
EndTime = ev.EndsAt,
|
||||
CreatorPlayerId = (int)ev.CreatorPlayerId,
|
||||
AttendeeCount = 0, // computed at query time when needed
|
||||
ev.RoomId,
|
||||
ImageName = string.Empty,
|
||||
Accessibility = 1, // Public
|
||||
var rows = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == eventId)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ResponseWire).ToList());
|
||||
}
|
||||
|
||||
// ── Wire shape ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The PlayerEvent fields the 2023 client carries that
|
||||
/// <see cref="PlayerEventEntity"/> has no column for. Adding columns needs
|
||||
/// a migration in <c>Data/Entities</c>, so these live in the PlayerSettings
|
||||
/// side-table: one JSON row per event under
|
||||
/// <c>playerevent:{id}:wire</c>, plus a flat <c>playerevent:{id}:club</c>
|
||||
/// row that keeps club lookups a single indexed query.
|
||||
///
|
||||
/// Public (not private) purely so System.Text.Json's reflection-based
|
||||
/// (de)serialiser can see the type and its accessors.</summary>
|
||||
public sealed class EventExtras
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
/// <summary>BAMLHAODDOG ordinal. The enum's member order is not
|
||||
/// recoverable from the ISIL (enums carry no method bodies), so events
|
||||
/// that never had accessibility set keep the historical default of 1
|
||||
/// rather than guessing a new one; once the client PUTs a value we
|
||||
/// echo exactly what it sent.</summary>
|
||||
public int Accessibility { get; set; } = 1;
|
||||
public bool IsMultiInstance { get; set; }
|
||||
public bool SupportMultiInstanceRoomChat { get; set; }
|
||||
public int DefaultBroadcastPermissions { get; set; }
|
||||
public int CanRequestBroadcastPermissions { get; set; }
|
||||
public long? BroadcastingRoomInstanceId { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
|
||||
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
|
||||
|
||||
/// <summary>Wire shape verified against the client's Utf8Json formatter
|
||||
/// <c>HPMMKFGDAEC.txt:1139-1518</c> (and the details variant
|
||||
/// <c>CENEMCMGDKG.txt:1191-1586</c>): PascalCase keys <c>PlayerEventId,
|
||||
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
|
||||
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
|
||||
/// IsMultiInstance, SupportMultiInstanceRoomChat,
|
||||
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
|
||||
/// BroadcastingRoomInstanceId</c> — property types read off the getters in
|
||||
/// <c>HPIOAGDJHDH.txt</c>. Server-side we store some of these under other
|
||||
/// names (Title, StartsAt, EndsAt), so remap explicitly.</summary>
|
||||
private static Dictionary<string, object?> ToWire(
|
||||
PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new()
|
||||
{
|
||||
["PlayerEventId"] = ev.Id,
|
||||
["CreatorPlayerId"] = (int)ev.CreatorPlayerId,
|
||||
["RoomId"] = ev.RoomId,
|
||||
["SubRoomId"] = extras.SubRoomId,
|
||||
["ClubId"] = extras.ClubId,
|
||||
["Name"] = ev.Title,
|
||||
["Description"] = ev.Description,
|
||||
["ImageName"] = extras.ImageName,
|
||||
["StartTime"] = ev.StartsAt,
|
||||
["EndTime"] = ev.EndsAt,
|
||||
["AttendeeCount"] = attendeeCount,
|
||||
["Accessibility"] = extras.Accessibility,
|
||||
["IsMultiInstance"] = extras.IsMultiInstance,
|
||||
["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat,
|
||||
["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions,
|
||||
["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions,
|
||||
["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId,
|
||||
// MDCBEPJCJPO (the includeDetails DTO) is this same object plus
|
||||
// Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless
|
||||
// formatter skips unknown keys, so emitting Tags unconditionally is
|
||||
// inert there and saves branching on includeDetails. Type is the
|
||||
// KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0.
|
||||
["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(),
|
||||
};
|
||||
|
||||
/// <summary>JPIKCIGABBI — <c>{PlayerEventResponseId, PlayerEventId,
|
||||
/// PlayerId(Int32), CreatedAt, Type}</c>, keys at
|
||||
/// <c>DDDLCGDEPNH.txt:395-502</c>, types off the getters in
|
||||
/// <c>JPIKCIGABBI.txt</c>.</summary>
|
||||
private static object ResponseWire(PlayerEventResponseEntity r) => new
|
||||
{
|
||||
PlayerEventResponseId = r.Id,
|
||||
PlayerEventId = r.EventId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
r.CreatedAt,
|
||||
Type = r.Response,
|
||||
};
|
||||
|
||||
/// <summary>PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
|
||||
/// </summary>
|
||||
private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new
|
||||
{
|
||||
PlayerEvent = ToWire(ev, extras, attendeeCount),
|
||||
Result = 0, // BNCFHOOCHAI success
|
||||
TagModifyResult = new { Result = 0, Tags = extras.Tags },
|
||||
};
|
||||
|
||||
private async Task<object> WrapperAsync(PlayerEventEntity ev)
|
||||
{
|
||||
var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id];
|
||||
var counts = await AttendeeCountsAsync(new[] { ev.Id });
|
||||
return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id));
|
||||
}
|
||||
|
||||
private async Task<List<Dictionary<string, object?>>> ToWireManyAsync(IReadOnlyList<PlayerEventEntity> rows)
|
||||
{
|
||||
if (rows.Count == 0) return [];
|
||||
var extras = await LoadExtrasAsync(rows);
|
||||
var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList());
|
||||
return rows
|
||||
.Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, Dictionary<string, object?>>> ToWireMapAsync(
|
||||
IReadOnlyList<PlayerEventEntity> rows)
|
||||
{
|
||||
var wire = await ToWireManyAsync(rows);
|
||||
var map = new Dictionary<long, Dictionary<string, object?>>();
|
||||
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
|
||||
return map;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object?>> ToWireOneAsync(PlayerEventEntity ev)
|
||||
=> (await ToWireManyAsync(new[] { ev }))[0];
|
||||
|
||||
private async Task<Dictionary<long, EventExtras>> LoadExtrasAsync(IReadOnlyCollection<PlayerEventEntity> events)
|
||||
{
|
||||
var map = new Dictionary<long, EventExtras>();
|
||||
foreach (var ev in events) map[ev.Id] = new EventExtras();
|
||||
if (map.Count == 0) return map;
|
||||
|
||||
var keys = map.Keys.Select(ExtrasKey).ToList();
|
||||
var rows = await db.PlayerSettings
|
||||
.AsNoTracking()
|
||||
.Where(s => keys.Contains(s.Key))
|
||||
.Select(s => new { s.Key, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue;
|
||||
var parsed = DeserializeExtras(row.Value);
|
||||
if (parsed is not null) map[id] = parsed;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static long? EventIdFromKey(string key)
|
||||
{
|
||||
var parts = key.Split(':');
|
||||
return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static EventExtras? DeserializeExtras(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<EventExtras>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras)
|
||||
{
|
||||
// PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a
|
||||
// long one can never truncate the JSON blob into unparseable garbage.
|
||||
if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList();
|
||||
extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList();
|
||||
|
||||
await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id),
|
||||
JsonSerializer.Serialize(extras));
|
||||
// Flat index row so "events for club X" stays one indexed lookup
|
||||
// instead of a scan through the JSON blobs.
|
||||
await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id),
|
||||
extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task UpsertSettingAsync(long playerId, string key, string value)
|
||||
{
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, int>> AttendeeCountsAsync(IReadOnlyCollection<long> eventIds)
|
||||
{
|
||||
if (eventIds.Count == 0) return new Dictionary<long, int>();
|
||||
var ids = eventIds.ToList();
|
||||
var rows = await db.PlayerEventResponses
|
||||
.Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse)
|
||||
.GroupBy(r => r.EventId)
|
||||
.Select(g => new { EventId = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
return rows.ToDictionary(r => r.EventId, r => r.Count);
|
||||
}
|
||||
|
||||
// ── Query shaping ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the
|
||||
/// query-key literal survives in the ISIL — enums have no method bodies to
|
||||
/// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and
|
||||
/// absent) reproduces the previous behaviour exactly, and a wrong guess for
|
||||
/// the others still returns a valid, non-empty event list rather than an
|
||||
/// error.</summary>
|
||||
private static IQueryable<PlayerEventEntity> ApplyScheduleFilter(
|
||||
IQueryable<PlayerEventEntity> rows, int? scheduleFilter)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return scheduleFilter switch
|
||||
{
|
||||
1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now),
|
||||
2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)),
|
||||
3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)),
|
||||
_ => rows.Where(e => e.EndsAt > now),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
|
||||
/// <see cref="ApplyScheduleFilter"/>; 0/absent keeps soonest-first.</summary>
|
||||
private static IQueryable<PlayerEventEntity> ApplySort(
|
||||
IQueryable<PlayerEventEntity> rows, int? sort) => sort switch
|
||||
{
|
||||
1 => rows.OrderByDescending(e => e.CreatedAt),
|
||||
2 => rows.OrderBy(e => e.EndsAt),
|
||||
_ => rows.OrderBy(e => e.StartsAt),
|
||||
};
|
||||
|
||||
/// <summary>Events attached to any of <paramref name="clubIds"/>. Explicit
|
||||
/// ClubId (persisted since the v2 create/edit body carries it) wins; when
|
||||
/// nothing carries one we fall back to the historical heuristic of "events
|
||||
/// in rooms owned by club members" so pre-existing data still populates the
|
||||
/// tab.</summary>
|
||||
private async Task<List<PlayerEventEntity>> ClubEventsAsync(
|
||||
IReadOnlyCollection<long> clubIds, int skip, int take)
|
||||
{
|
||||
var explicitIds = await EventIdsForClubsAsync(clubIds);
|
||||
IQueryable<PlayerEventEntity> q;
|
||||
if (explicitIds.Count > 0)
|
||||
{
|
||||
q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var roomIds = await ClubRoomIdsAsync(clubIds);
|
||||
if (roomIds.Count == 0) return [];
|
||||
q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<long>> EventIdsForClubsAsync(IReadOnlyCollection<long> clubIds)
|
||||
{
|
||||
if (clubIds.Count == 0) return [];
|
||||
var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList();
|
||||
var keys = await db.PlayerSettings
|
||||
.AsNoTracking()
|
||||
.Where(s => wanted.Contains(s.Value)
|
||||
&& s.Key.StartsWith("playerevent:")
|
||||
&& s.Key.EndsWith(":club"))
|
||||
.Select(s => s.Key)
|
||||
.ToListAsync();
|
||||
|
||||
return keys
|
||||
.Select(EventIdFromKey)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<long>> ClubRoomIdsAsync(IReadOnlyCollection<long> clubIds)
|
||||
{
|
||||
var memberships = db.ClubMemberships.AsQueryable();
|
||||
if (clubIds.Count > 0)
|
||||
{
|
||||
var ids = clubIds.ToList();
|
||||
memberships = memberships.Where(m => ids.Contains(m.ClubId));
|
||||
}
|
||||
|
||||
var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync();
|
||||
if (memberIds.Count == 0) return [];
|
||||
return await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// ── Create / edit (v2) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v2</c> — the 2023 create-event flow.
|
||||
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
|
||||
/// <c>CBKANFIOBCF.txt:2390</c>) whose 14 keys are
|
||||
/// <c>RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
|
||||
/// StartTime, EndTime, Accessibility, IsMultiInstance,
|
||||
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
|
||||
/// CanRequestBroadcastPermissions</c> (<c>ACKNGEEHHAE.txt:935-1234</c>),
|
||||
/// and reads the PHHAKLPGNGC wrapper back. Only <c>[HttpGet]</c> was
|
||||
/// registered on this path, so creating an event 405'd.</summary>
|
||||
[HttpPost("api/playerevents/v2")]
|
||||
public async Task<IActionResult> CreateV2()
|
||||
{
|
||||
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty;
|
||||
if (name.Length == 0) return BadRequest("missing_name");
|
||||
|
||||
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow;
|
||||
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1);
|
||||
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty;
|
||||
|
||||
var evt = new PlayerEventEntity
|
||||
{
|
||||
CreatorPlayerId = Me,
|
||||
RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0,
|
||||
Title = Clamp(name, 128),
|
||||
Description = Clamp(description, 2000),
|
||||
StartsAt = start,
|
||||
EndsAt = end > start ? end : start.AddHours(1),
|
||||
};
|
||||
db.PlayerEvents.Add(evt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var extras = new EventExtras();
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v2/{eventId}</c> — edit-event save.
|
||||
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
|
||||
/// (<c>CBKANFIOBCF.txt:2595</c>, route literal <c>:2579</c>); the path was
|
||||
/// GET-only, so editing 405'd.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}")]
|
||||
public async Task<IActionResult> EditV2(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim();
|
||||
if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128);
|
||||
|
||||
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim();
|
||||
if (description is not null) evt.Description = Clamp(description, 2000);
|
||||
|
||||
var roomId = await ReadLongFieldAsync("RoomId", "roomId");
|
||||
if (roomId is long room && room > 0) evt.RoomId = room;
|
||||
|
||||
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt");
|
||||
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt");
|
||||
if (start is DateTime s) evt.StartsAt = s;
|
||||
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>Copies whatever subset of the BIEFKAOABMP body is present onto
|
||||
/// <paramref name="extras"/>. Absent keys are left alone so the same helper
|
||||
/// serves both create and edit.</summary>
|
||||
private async Task ApplyBodyToExtrasAsync(EventExtras extras)
|
||||
{
|
||||
var fields = await ReadBodyAsync();
|
||||
|
||||
if (fields.Fields.ContainsKey("SubRoomId"))
|
||||
extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId");
|
||||
if (fields.Fields.ContainsKey("ClubId"))
|
||||
extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId");
|
||||
|
||||
var imageName = await ReadStringFieldAsync("ImageName", "imageName");
|
||||
if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128);
|
||||
|
||||
if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility)
|
||||
extras.Accessibility = accessibility;
|
||||
if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi)
|
||||
extras.IsMultiInstance = multi;
|
||||
if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"supportsMultiInstanceRoomChat") is bool chat)
|
||||
extras.SupportMultiInstanceRoomChat = chat;
|
||||
if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def)
|
||||
extras.DefaultBroadcastPermissions = def;
|
||||
if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req)
|
||||
extras.CanRequestBroadcastPermissions = req;
|
||||
|
||||
var tags = await ReadStringListFieldAsync("Tags", "tags");
|
||||
if (tags is not null) extras.Tags = tags;
|
||||
}
|
||||
|
||||
// ── Create / RSVP (v1, shared with the 2020 client) ──────────────────
|
||||
|
||||
public sealed record CreateEventRequest(
|
||||
string Title, string? Description, long RoomId,
|
||||
DateTime StartsAt, DateTime EndsAt, int Capacity);
|
||||
@@ -295,11 +780,7 @@ public class PlayerEventsController(
|
||||
};
|
||||
db.PlayerEvents.Add(ev);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ev.Id, ev.Title, ev.Description, ev.RoomId,
|
||||
ev.StartsAt, ev.EndsAt, ev.Capacity,
|
||||
});
|
||||
return Ok(await ToWireOneAsync(ev));
|
||||
}
|
||||
|
||||
public sealed record RsvpRequest(int Response);
|
||||
@@ -328,25 +809,45 @@ public class PlayerEventsController(
|
||||
await notifications.NotifyAsync(ev.CreatorPlayerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, From = Me, body.Response });
|
||||
// Left as the historical shape on purpose: this route is 2020-only
|
||||
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
||||
// so there is no 2023 DTO to match here.
|
||||
return Ok(new { row.EventId, row.PlayerId, row.Response });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
|
||||
/// caller's RSVP state for an event. <c>Response</c> is the
|
||||
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
|
||||
/// Idempotent — re-responding overwrites.
|
||||
///
|
||||
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
|
||||
/// 0=Success, plus various error codes). Returning an object
|
||||
/// here throws InvalidCastException on the 2020.12 deserialiser
|
||||
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
|
||||
/// under the playerevents/v1/respond section.</summary>
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the 2020 watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically. The 2023 client GETs this
|
||||
/// path instead — see <see cref="ResponsesForEvent"/>.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the caller's RSVP
|
||||
/// state. Two client generations share this route with different
|
||||
/// contracts:
|
||||
/// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning
|
||||
/// an object throws InvalidCastException there — see
|
||||
/// <c>recroom-2020-client-response-contracts.md</c>.
|
||||
/// * 2023-03 posts a RawJsonForm body <c>{PlayerEventId, Type}</c>
|
||||
/// (NKIFBKJALEJ, keys at <c>GFHNJMMBHFD.txt:203-238</c>; body build at
|
||||
/// <c>CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112</c>) and reads
|
||||
/// CEKABGOIOAF, an OBJECT <c>{Result:int}</c>
|
||||
/// (<c>GJJGDENPOAP.txt:139-150</c>).
|
||||
/// Branch the response shape on the body encoding the caller actually
|
||||
/// used — that is the only signal that distinguishes them.</summary>
|
||||
[HttpPost("api/playerevents/v1/respond")]
|
||||
public async Task<IActionResult> RespondForm(
|
||||
[FromForm(Name = "EventId")] long? eventId,
|
||||
[FromForm(Name = "Response")] int response = 0)
|
||||
public async Task<IActionResult> RespondForm()
|
||||
{
|
||||
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
|
||||
var legacyForm = Request.HasFormContentType;
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0;
|
||||
if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt))
|
||||
return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var existing = await db.PlayerEventResponses
|
||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||
if (existing is null)
|
||||
@@ -354,35 +855,65 @@ public class PlayerEventsController(
|
||||
else
|
||||
existing.Response = response;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
|
||||
/// the caller's RSVP row. Same bare-int wire shape as
|
||||
/// <see cref="RespondForm"/>.</summary>
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop the
|
||||
/// caller's RSVP row. Same two-generation split as
|
||||
/// <see cref="RespondForm"/>. The 2023 body is
|
||||
/// <c>JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78</c>) whose single field
|
||||
/// is name-preserved: <c>public long PlayerEventId</c>
|
||||
/// (2023.06.21 <c>dump.cs:1291301</c>).</summary>
|
||||
[HttpPost("api/playerevents/v1/deleteResponse")]
|
||||
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
|
||||
public async Task<IActionResult> DeleteResponseForm()
|
||||
{
|
||||
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
|
||||
var legacyForm = Request.HasFormContentType;
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created.</summary>
|
||||
private IActionResult RespondResult(bool legacyForm, int code)
|
||||
=> legacyForm ? Ok(code) : Ok(new { Result = code });
|
||||
|
||||
/// <summary>DELETE/POST <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
|
||||
/// (<c>CBKANFIOBCF.txt:4166</c>, route literal <c>:4156</c>) and reads the
|
||||
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
|
||||
/// DELETE stays for the 2020 client.</summary>
|
||||
[HttpPost("api/playerevents/v2/delete/{id:long}")]
|
||||
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
|
||||
public async Task<IActionResult> DeleteByPath(long id)
|
||||
{
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
// Snapshot the wire shape before the rows go away — the wrapper still
|
||||
// has to echo the event that was deleted.
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
||||
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
||||
|
||||
db.PlayerEvents.Remove(evt);
|
||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
return Ok(Wrapper(evt, extras, attendees));
|
||||
}
|
||||
|
||||
// ── Field mutations (v2) ─────────────────────────────────────────────
|
||||
// Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose
|
||||
// fields arrive as x-www-form-urlencoded, and every one returns the
|
||||
// PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent
|
||||
// null right after a successful save. POST is kept alongside for the 2020
|
||||
// client.
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||
public async Task<IActionResult> UpdateName(long eventId)
|
||||
@@ -391,10 +922,10 @@ public class PlayerEventsController(
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("name", "Name", "value");
|
||||
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
|
||||
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
|
||||
evt.Title = Clamp(value.Trim(), 128);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
|
||||
@@ -404,12 +935,17 @@ public class PlayerEventsController(
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
|
||||
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
|
||||
evt.Description = Clamp(value.Trim(), 2000);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/room</c> — FPPNHCMPAKK sends
|
||||
/// <c>roomId</c> AND an optional <c>subRoomId</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101</c>); dropping the
|
||||
/// latter meant the event never pointed at the sub-room the host picked.
|
||||
/// </summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
|
||||
public async Task<IActionResult> UpdateRoom(long eventId)
|
||||
@@ -420,9 +956,11 @@ public class PlayerEventsController(
|
||||
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
|
||||
evt.RoomId = id;
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
|
||||
@@ -438,7 +976,7 @@ public class PlayerEventsController(
|
||||
if (start is null && end is null) return BadRequest("missing_time");
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
|
||||
@@ -448,8 +986,10 @@ public class PlayerEventsController(
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "image", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.ImageName = Clamp(value.Trim(), 128);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
@@ -460,44 +1000,92 @@ public class PlayerEventsController(
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
|
||||
if (value is not int accessibility) return BadRequest("missing_accessibility");
|
||||
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Accessibility = accessibility;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/tags</c>. JHLMDMGELFD does NOT
|
||||
/// send form fields — it serialises <c>List<String></c> straight into
|
||||
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
|
||||
/// (<c>CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81</c>). The old field
|
||||
/// reader only looked inside JSON objects, so every save stored an empty
|
||||
/// string and silently cleared the tags. The wrapper's
|
||||
/// <c>TagModifyResult.Tags</c> is what the client re-reads afterwards.
|
||||
/// </summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
|
||||
public async Task<IActionResult> UpdateTags(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "tags", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List<string>();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Tags = tags;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/multiinstance</c>. IEGDKKAKHBD
|
||||
/// sends FOUR form fields — <c>isMultiInstance</c>,
|
||||
/// <c>supportsMultiInstanceRoomChat</c>, <c>defaultBroadcastPermissions</c>,
|
||||
/// <c>canRequestBroadcastPermissions</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161</c>). The handler used
|
||||
/// to look for a field named <c>multiInstance</c>, which the client never
|
||||
/// sends, so the toggle always stored false and the other three were
|
||||
/// dropped. Note the request spells it "supports…" while the response key
|
||||
/// is "Support…" — both spellings are accepted here.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
public async Task<IActionResult> UpdateMultiInstance(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
|
||||
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
|
||||
return Ok(ToWire(evt));
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
|
||||
extras.IsMultiInstance =
|
||||
await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false;
|
||||
extras.SupportMultiInstanceRoomChat =
|
||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"SupportMultiInstanceRoomChat") ?? false;
|
||||
extras.DefaultBroadcastPermissions =
|
||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
||||
extras.CanRequestBroadcastPermissions =
|
||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/club</c>. JLHJHGANFDM takes a
|
||||
/// <c>Nullable<Int64></c> and boxes it into the single form field
|
||||
/// <c>clubId</c> (<c>CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71</c>) — a
|
||||
/// null therefore arrives as an absent/empty field, which is the client's
|
||||
/// only way to DETACH the club. Rejecting that with 400 made detaching
|
||||
/// impossible.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
|
||||
public async Task<IActionResult> UpdateClub(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
|
||||
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
|
||||
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
await SetEventSettingAsync(evt, "club", id.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
if (clubId is long id && id > 0)
|
||||
{
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
extras.ClubId = id;
|
||||
}
|
||||
else
|
||||
{
|
||||
extras.ClubId = null;
|
||||
}
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||
@@ -512,10 +1100,13 @@ public class PlayerEventsController(
|
||||
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
|
||||
db.PlayerEventResponses.RemoveRange(responses);
|
||||
await db.SaveChangesAsync();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
|
||||
// ── Bulk-invite ──────────────────────────────────────────────────────
|
||||
|
||||
public sealed class BulkInviteRequest
|
||||
{
|
||||
@@ -523,12 +1114,17 @@ public class PlayerEventsController(
|
||||
public List<int>? InvitedPlayerIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
|
||||
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
|
||||
/// InvitedPlayerIds:List<int>}</c>; response
|
||||
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
|
||||
/// We insert one MessageEntity per recipient so the watch's inbox
|
||||
/// shows the invite, and push a per-recipient notification.</summary>
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c>. Request is
|
||||
/// <c>JsonUtility.ToJson(RecNet.Events.BulkInviteRequest)</c> —
|
||||
/// name-preserved fields <c>PlayerEventId</c> / <c>InvitedPlayerIds</c>
|
||||
/// (2023.06.21 <c>dump.cs:1288323-1288324</c>, body build at
|
||||
/// <c>CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96</c>). Response is
|
||||
/// IDELKAJIOLI <c>{FailedInvites, Result}</c>
|
||||
/// (<c>GAODOHODBDE.txt:203-238</c>) where each failed entry is
|
||||
/// <c>{InvitedPlayerId, Result}</c> (<c>MOJLGDKFECO.txt:203-238</c>) — the
|
||||
/// entries used to be <c>{PlayerId, Error}</c> and deserialised as all
|
||||
/// defaults. Error paths return the same JSON shape rather than a
|
||||
/// NotFound()/Forbid() body the client cannot parse.</summary>
|
||||
[HttpPost("api/playerevents/v1/bulkInvite")]
|
||||
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
|
||||
{
|
||||
@@ -536,8 +1132,11 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
|
||||
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
// BNCFHOOCHAI's member order isn't recoverable (enums have no method
|
||||
// bodies to disassemble); any non-zero code reads as "failed" on the
|
||||
// client, which is all the invite sheet branches on.
|
||||
if (ev is null) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
|
||||
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
|
||||
|
||||
var failed = new List<object>();
|
||||
var sender = Me;
|
||||
@@ -547,7 +1146,7 @@ public class PlayerEventsController(
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||
if (!exists)
|
||||
{
|
||||
failed.Add(new { PlayerId = rid, Error = "unknown player" });
|
||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||
continue;
|
||||
}
|
||||
db.Messages.Add(new MessageEntity
|
||||
@@ -564,45 +1163,47 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class BroadcastRequest
|
||||
/// <summary>POST <c>api/playerevents/v1/broadcast</c> — set or clear the
|
||||
/// room instance an event is being broadcast from. JBHPMONELAO posts
|
||||
/// <c>RecNet.Events.BroadcastRoomInstanceRequest</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81</c>) whose JSON keys are
|
||||
/// <c>PlayerEventId</c> and the nullable <c>BroadcastRoomInstanceId</c>
|
||||
/// (<c>EOFEKNIHNNC.txt:215-258</c>), and reads the PHHAKLPGNGC wrapper.
|
||||
/// This used to be implemented as a text-message blast to every RSVP,
|
||||
/// which both spammed attendees and returned an unparseable body.</summary>
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast()
|
||||
{
|
||||
public long PlayerEventId { get; set; }
|
||||
public string? Message { get; set; }
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long id) return BadRequest("missing_event");
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id);
|
||||
if (evt is null) return NotFound();
|
||||
if (evt.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync(
|
||||
"BroadcastRoomInstanceId", "broadcastRoomInstanceId",
|
||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
||||
// them to re-fetch instead of sending a chat message.
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
|
||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var recipients = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == ev.Id && r.Response != 2)
|
||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var body = string.IsNullOrWhiteSpace(req.Message)
|
||||
? $"Update for '{ev.Title}'"
|
||||
: req.Message.Trim();
|
||||
foreach (var playerId in recipients.Where(id => id != Me))
|
||||
{
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = playerId,
|
||||
Body = body,
|
||||
});
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, ev.Title, Message = body });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Sent = recipients.Count });
|
||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
||||
}
|
||||
|
||||
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
{
|
||||
@@ -611,174 +1212,6 @@ public class PlayerEventsController(
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
AddDelimitedIds(ids, value);
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(key, out var prop))
|
||||
AddJsonIds(ids, prop);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddJsonIds(ids, doc.RootElement);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJsonIds(List<long> ids, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
AddJsonIds(ids, item);
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
AddDelimitedIds(ids, element.GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var fields = await ReadRequestFieldsAsync();
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return bool.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return DateTime.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
|
||||
{
|
||||
const string itemKey = "__playerevent_fields";
|
||||
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
|
||||
&& cached is Dictionary<string, string> existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[itemKey] = fields;
|
||||
return fields;
|
||||
}
|
||||
|
||||
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
|
||||
{
|
||||
var settingKey = $"playerevent:{evt.Id}:{key}";
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = evt.CreatorPlayerId,
|
||||
Key = settingKey,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
||||
/// moderation report against an event. Wire response is
|
||||
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
||||
@@ -800,4 +1233,213 @@ public class PlayerEventsController(
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// ── Request parsing ──────────────────────────────────────────────────
|
||||
|
||||
private static string Clamp(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max];
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
var body = await ReadBodyAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "eventIds", "ids" })
|
||||
{
|
||||
if (body.Repeated.TryGetValue(key, out var values))
|
||||
foreach (var value in values) AddDelimitedIds(ids, value);
|
||||
}
|
||||
if (body.RootArray is not null)
|
||||
foreach (var value in body.RootArray) AddDelimitedIds(ids, value);
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var body = await ReadBodyAsync();
|
||||
foreach (var name in names)
|
||||
if (body.Fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Reads a list-valued field. Handles all three encodings the
|
||||
/// client uses: a bare JSON array body (the tags PUT), a JSON array nested
|
||||
/// under a key (the v2 create/edit body's <c>Tags</c>), and repeated /
|
||||
/// comma-joined form values.</summary>
|
||||
private async Task<List<string>?> ReadStringListFieldAsync(params string[] names)
|
||||
{
|
||||
var body = await ReadBodyAsync();
|
||||
if (body.RootArray is not null) return body.RootArray;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1)
|
||||
return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList();
|
||||
|
||||
if (!body.Fields.TryGetValue(name, out var raw)) continue;
|
||||
raw = raw.Trim();
|
||||
if (raw.Length == 0) return new List<string>();
|
||||
if (raw.StartsWith('['))
|
||||
{
|
||||
var parsed = ParseJsonStringArray(raw);
|
||||
if (parsed is not null) return parsed;
|
||||
}
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseJsonStringArray(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
|
||||
return ElementToStrings(doc.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ElementToStrings(JsonElement array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText();
|
||||
if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
if (bool.TryParse(value, out var parsed)) return parsed;
|
||||
// Form-encoded booleans arrive as "True"/"False" from the client's
|
||||
// boxed-bool ToString, but a JSON body may carry 1/0.
|
||||
return value switch { "1" => true, "0" => false, _ => null };
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
// The client always sends UTC (Utf8Json ISO-8601 with a Z, or
|
||||
// DateTime.ToString() on an already-UTC value). Force the parse to land
|
||||
// on Kind=Utc so it compares correctly against DateTime.UtcNow.
|
||||
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Everything the request carries, read once and cached — the body
|
||||
/// stream can only be consumed a single time.</summary>
|
||||
private sealed class ParsedBody
|
||||
{
|
||||
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, List<string>> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Set when the whole body is a bare JSON array — the shape
|
||||
/// the tags PUT sends.</summary>
|
||||
public List<string>? RootArray { get; set; }
|
||||
}
|
||||
|
||||
private const string BodyItemKey = "__playerevent_body";
|
||||
|
||||
private async Task<ParsedBody> ReadBodyAsync()
|
||||
{
|
||||
if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing)
|
||||
return existing;
|
||||
|
||||
var body = new ParsedBody();
|
||||
foreach (var pair in Request.Query)
|
||||
{
|
||||
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
{
|
||||
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
|
||||
}
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0)
|
||||
{
|
||||
// Read the raw text rather than gating on Content-Type: the client's
|
||||
// RawJsonForm does set application/json, but the JSON path is also
|
||||
// the fallback for anything else that isn't a form.
|
||||
using var reader = new StreamReader(
|
||||
Request.Body, System.Text.Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
switch (doc.RootElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
if (prop.Value.ValueKind == JsonValueKind.Array)
|
||||
body.Repeated[prop.Name] = ElementToStrings(prop.Value);
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
body.RootArray = ElementToStrings(doc.RootElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Empty or non-JSON body → query/form values only.
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[BodyItemKey] = body;
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user