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; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.PlayerEvents; /// /// 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 RecNet.Runtime/CBKANFIOBCF /// (route literals at CBKANFIOBCF.txt:161-4156). Three wire types drive /// nearly every handler here: /// * HPIOAGDJHDH (PlayerEvent) — 17 keys, formatter /// HPMMKFGDAEC.txt:1139-1518. /// * MDCBEPJCJPO (PlayerEvent + Tags:[{Tag,Type}]) — formatter /// CENEMCMGDKG.txt:1191-1586, tag entry keys /// PCMLHLIBLNJ.txt:191-218. /// * PHHAKLPGNGC ({PlayerEvent,Result,TagModifyResult}) — /// formatter EEAAGIJHLOJ.txt:267-326, TagModifyResult keys /// ALJPHDEAHBK.txt:191-218. Every v2 create/edit/delete/field /// mutation deserialises this wrapper, so a flat event body leaves the /// client's PlayerEvent null after a save. /// [ApiController] [Authorize] public class PlayerEventsController( DorkNetDbContext db, NotificationService notifications) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); /// PlayerEventResponseType.NotGoing — the one RSVP state that /// does not count towards AttendeeCount. private const int NotGoingResponse = 2; // ── Browse ─────────────────────────────────────────────────────────── /// GET api/playerevents/v2 (and v1) — upcoming /// events list, a BARE JSON array of PlayerEvent. /// /// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC /// returns List<HPIOAGDJHDH> and dispatches with verb 0 /// (CBKANFIOBCF.txt:1305, route literal :1304). Only /// [HttpPost] was registered on api/playerevents/v1, so the /// whole tab 405'd. [AllowAnonymous] [HttpGet("api/playerevents/v2")] [HttpGet("api/playerevents/v1")] public async Task ListUpcoming( [FromQuery] int skip = 0, [FromQuery] int take = 50) { skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); var rows = await db.PlayerEvents .Where(e => e.EndsAt > DateTime.UtcNow) .OrderBy(e => e.StartsAt) .Skip(skip) .Take(take) .ToListAsync(); return Ok(await ToWireManyAsync(rows)); } /// GET api/playerevents/v1|v2/{id}. The client calls this /// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with /// ?includeDetails= / ?clubId= (HLJBCGMONEH / OAKKDBLNKLG → /// MDCBEPJCJPO, query keys at /// CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99). Both DTOs are the /// same object, the detail one just adds Tags, and the Utf8Json /// formatters skip unknown keys — so we always emit the superset and no /// branch is needed. [AllowAnonymous] [HttpGet("api/playerevents/v2/{eventId:long}")] [HttpGet("api/playerevents/v1/{eventId:long}")] public async Task GetOne(long eventId) { var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); if (ev is null) return NotFound(); return Ok(await ToWireOneAsync(ev)); } /// GET api/playerevents/v1/search. Query keys are /// query, sort and scheduleFilter /// (CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126); the latter /// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE). [AllowAnonymous] [HttpGet("api/playerevents/v1/search")] public async Task 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 = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter); if (needle.Length > 0) { rows = rows.Where(e => e.Title.ToLower().Contains(needle) || e.Description.ToLower().Contains(needle)); } var result = await ApplySort(rows, sort) .Skip(skip) .Take(take) .ToListAsync(); return Ok(await ToWireManyAsync(result)); } /// GET api/playerevents/v1/searchlive — "happening now". /// Separate handler from because the response element /// type differs: FIKHCEPDIBF returns List<CLDDIKOJMAM>, which /// is the PlayerEvent object plus PlayerCount (Int32) and /// IsFull (Boolean) — formatter PDIFGFKAMBG.txt:1267,1294. /// Head-count comes from the room's live GameSessions rows (their RoomId is /// the numeric room id as a string, see /// Services/GameSessionService.cs:91-96). [AllowAnonymous] [HttpGet("api/playerevents/v1/searchlive")] public async Task 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] [HttpGet("api/playerevents/v1/room/{roomId:long}")] public async Task ForRoom(long roomId) { var rows = await db.PlayerEvents .Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow) .OrderBy(e => e.StartsAt) .Take(100) .ToListAsync(); return Ok(await ToWireManyAsync(rows)); } /// GET api/playerevents/v1/club/{clubId}. PHNEDJAPIKI /// returns IOKLNPFOLGI — an OBJECT /// {Events:[PlayerEvent],ContinuationToken:String} /// (GHBIGGJKPFI.txt:203-238), not a bare array, and passes /// take / continuationToken query params /// (CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89). 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). [AllowAnonymous] [HttpGet("api/playerevents/v1/club/{clubId:long}")] public async Task ForClub( long clubId, [FromQuery] int? take = null, [FromQuery] string? continuationToken = null) { var pageSize = Math.Clamp(take ?? 50, 1, 100); var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0; // 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); return Ok(new { Events = await ToWireManyAsync(rows), ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty, }); } /// GET api/playerevents/v1/clubs — events for a specific /// set of clubs. IEGLMOOPLED appends one repeated id query value per /// club (CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59); ignoring them /// returned every club's events and the watch could not bucket the list. /// [AllowAnonymous] [HttpGet("api/playerevents/v1/clubs")] public async Task ClubEvents([FromQuery] int take = 100) { take = Math.Clamp(take, 1, 200); var clubIds = new List(); 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 { public List? PlayerEventIds { get; set; } public List? EventIds { get; set; } public List? Ids { get; set; } } /// POST api/playerevents/v1/bulk — verb 2 at /// CBKANFIOBCF.txt:660; the form field is Ids /// (CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64). GET is kept for the /// 2020 client, which puts the ids in the query string. [AllowAnonymous] [HttpPost("api/playerevents/v1/bulk")] [HttpGet("api/playerevents/v1/bulk")] public async Task Bulk() { var ids = await ReadEventIdsAsync(); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await db.PlayerEvents .Where(e => ids.Contains(e.Id)) .ToListAsync(); return Ok(await ToWireManyAsync(rows)); } /// GET api/playerevents/v1/tagfilters — AKCLLEJNFFD reads /// three String lists: PinnedFilters, PopularFilters and /// TrendingFilters (HMDONHHKKGA.txt:279-346). A missing key /// leaves the list null and the chips UI NREs when it enumerates. [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" }, }); /// GET api/playerevents/v1/all — the watch's /// LocalPlayerEventInfo fetch (caller's created + RSVP'd events). /// PCELKKHNPHJ is {Created:[PlayerEvent], Responses:[COAGAJPELCG]} /// (ENJDEONHNMO.txt:191-218) where each response entry is /// {PlayerEvent, PlayerEventResponse} (HJKLIIPPPEM.txt:215-258) /// — a flat response row leaves PlayerEvent null and the My Events /// page NREs. [AllowAnonymous] [HttpGet("api/playerevents/v1/all")] public async Task AllPlayerEvents() { var pid = this.CurrentPlayerId(); if (pid is not long me) return Ok(new { Created = Array.Empty(), Responses = Array.Empty() }); return await AllForPlayerAsync(me); } [AllowAnonymous] [HttpGet("api/playerevents/v1/all/{accountId:long}")] public Task AllPlayerEventsForAccount(long accountId) => AllForPlayerAsync(accountId); private async Task AllForPlayerAsync(long playerId) { var created = await db.PlayerEvents .Where(e => e.CreatorPlayerId == playerId) .OrderByDescending(e => e.StartsAt) .Take(200) .ToListAsync(); var responses = await db.PlayerEventResponses .Where(r => r.PlayerId == playerId) .OrderByDescending(r => r.CreatedAt) .Take(200) .ToListAsync(); // 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(); 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(), }); } /// GET api/playerevents/v1/{eventId}/responses — the event /// detail page's attendee list. DFABBIDLDPE dispatches with verb 0 /// (CBKANFIOBCF.txt:1699, route literal :1689) and reads /// List<JPIKCIGABBI>; the path was registered POST-only, so /// the list 405'd. [AllowAnonymous] [HttpGet("api/playerevents/v1/{eventId:long}/responses")] public async Task ResponsesForEvent(long eventId) { 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 ─────────────────────────────────────────────────────── /// The PlayerEvent fields the 2023 client carries that /// has no column for. Adding columns needs /// a migration in Data/Entities, so these live in the PlayerSettings /// side-table: one JSON row per event under /// playerevent:{id}:wire, plus a flat playerevent:{id}:club /// 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. public sealed class EventExtras { public long? SubRoomId { get; set; } public long? ClubId { get; set; } public string ImageName { get; set; } = string.Empty; /// 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. 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 Tags { get; set; } = []; } private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire"; private static string ClubKey(long eventId) => $"playerevent:{eventId}:club"; /// Wire shape verified against the client's Utf8Json formatter /// HPMMKFGDAEC.txt:1139-1518 (and the details variant /// CENEMCMGDKG.txt:1191-1586): PascalCase keys PlayerEventId, /// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description, /// ImageName, StartTime, EndTime, AttendeeCount, Accessibility, /// IsMultiInstance, SupportMultiInstanceRoomChat, /// DefaultBroadcastPermissions, CanRequestBroadcastPermissions, /// BroadcastingRoomInstanceId — property types read off the getters in /// HPIOAGDJHDH.txt. Server-side we store some of these under other /// names (Title, StartsAt, EndsAt), so remap explicitly. private static Dictionary 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(), }; /// JPIKCIGABBI — {PlayerEventResponseId, PlayerEventId, /// PlayerId(Int32), CreatedAt, Type}, keys at /// DDDLCGDEPNH.txt:395-502, types off the getters in /// JPIKCIGABBI.txt. private static object ResponseWire(PlayerEventResponseEntity r) => new { PlayerEventResponseId = r.Id, PlayerEventId = r.EventId, PlayerId = (int)r.PlayerId, r.CreatedAt, Type = r.Response, }; /// PHHAKLPGNGC — the wrapper every v2 mutation deserialises. /// 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 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>> ToWireManyAsync(IReadOnlyList 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>> ToWireMapAsync( IReadOnlyList rows) { var wire = await ToWireManyAsync(rows); var map = new Dictionary>(); for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i]; return map; } private async Task> ToWireOneAsync(PlayerEventEntity ev) => (await ToWireManyAsync(new[] { ev }))[0]; private async Task> LoadExtrasAsync(IReadOnlyCollection events) { var map = new Dictionary(); 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(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> AttendeeCountsAsync(IReadOnlyCollection eventIds) { if (eventIds.Count == 0) return new Dictionary(); 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 ──────────────────────────────────────────────────── /// '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. private static IQueryable ApplyScheduleFilter( IQueryable 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), }; } /// 'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as /// ; 0/absent keeps soonest-first. private static IQueryable ApplySort( IQueryable rows, int? sort) => sort switch { 1 => rows.OrderByDescending(e => e.CreatedAt), 2 => rows.OrderBy(e => e.EndsAt), _ => rows.OrderBy(e => e.StartsAt), }; /// Events attached to any of . 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. private async Task> ClubEventsAsync( IReadOnlyCollection clubIds, int skip, int take) { var explicitIds = await EventIdsForClubsAsync(clubIds); IQueryable 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> EventIdsForClubsAsync(IReadOnlyCollection 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> ClubRoomIdsAsync(IReadOnlyCollection 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) ─────────────────────────────────────────────── /// POST api/playerevents/v2 — the 2023 create-event flow. /// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at /// CBKANFIOBCF.txt:2390) whose 14 keys are /// RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName, /// StartTime, EndTime, Accessibility, IsMultiInstance, /// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions, /// CanRequestBroadcastPermissions (ACKNGEEHHAE.txt:935-1234), /// and reads the PHHAKLPGNGC wrapper back. Only [HttpGet] was /// registered on this path, so creating an event 405'd. [HttpPost("api/playerevents/v2")] public async Task 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)); } /// POST api/playerevents/v2/{eventId} — edit-event save. /// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2 /// (CBKANFIOBCF.txt:2595, route literal :2579); the path was /// GET-only, so editing 405'd. [HttpPost("api/playerevents/v2/{eventId:long}")] public async Task 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)); } /// Copies whatever subset of the BIEFKAOABMP body is present onto /// . Absent keys are left alone so the same helper /// serves both create and edit. 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); [HttpPost("api/playerevents/v1/create")] [HttpPost("api/playerevents/v1")] public async Task Create([FromBody] CreateEventRequest req) { if (string.IsNullOrWhiteSpace(req.Title)) return BadRequest("missing_title"); var ev = new PlayerEventEntity { CreatorPlayerId = Me, RoomId = req.RoomId, Title = req.Title.Trim(), Description = (req.Description ?? string.Empty).Trim(), StartsAt = req.StartsAt, EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1), Capacity = Math.Max(0, req.Capacity), }; db.PlayerEvents.Add(ev); await db.SaveChangesAsync(); return Ok(await ToWireOneAsync(ev)); } public sealed record RsvpRequest(int Response); [HttpPost("api/playerevents/v1/{eventId:long}/rsvp")] public async Task Rsvp(long eventId, [FromBody] RsvpRequest body) { if (body.Response < 0 || body.Response > 2) return BadRequest("invalid_response"); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); if (ev is null) return NotFound(); var row = await db.PlayerEventResponses .FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me); if (row is null) { row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me }; db.PlayerEventResponses.Add(row); } row.Response = body.Response; await db.SaveChangesAsync(); // Tell the creator someone RSVP'd. They can ignore but the // watch will refresh their event detail card. if (ev.CreatorPlayerId != Me) 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 }); } /// POST api/playerevents/v1/{eventId}/responses — /// alternate RSVP route the 2020 watch uses for the response-list /// flow (older / sub-room view). Body is the same RsvpRequest /// as v1/{id}/rsvp; behaves identically. The 2023 client GETs this /// path instead — see . [HttpPost("api/playerevents/v1/{eventId:long}/responses")] public Task RespondList(long eventId, [FromBody] RsvpRequest body) => Rsvp(eventId, body); /// POST api/playerevents/v1/respond — 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 /// recroom-2020-client-response-contracts.md. /// * 2023-03 posts a RawJsonForm body {PlayerEventId, Type} /// (NKIFBKJALEJ, keys at GFHNJMMBHFD.txt:203-238; body build at /// CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112) and reads /// CEKABGOIOAF, an OBJECT {Result:int} /// (GJJGDENPOAP.txt:139-150). /// Branch the response shape on the body encoding the caller actually /// used — that is the only signal that distinguishes them. [HttpPost("api/playerevents/v1/respond")] public async Task RespondForm() { 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) db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response }); else existing.Response = response; await db.SaveChangesAsync(); return RespondResult(legacyForm, 0 /*Success*/); } /// POST api/playerevents/v1/deleteResponse — drop the /// caller's RSVP row. Same two-generation split as /// . The 2023 body is /// JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest) /// (CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78) whose single field /// is name-preserved: public long PlayerEventId /// (2023.06.21 dump.cs:1291301). [HttpPost("api/playerevents/v1/deleteResponse")] public async Task DeleteResponseForm() { 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 RespondResult(legacyForm, 0 /*Success*/); } private IActionResult RespondResult(bool legacyForm, int code) => legacyForm ? Ok(code) : Ok(new { Result = code }); /// DELETE/POST api/playerevents/v2/delete/{id} — hard /// delete an event the caller created. GJMCEDALKJO dispatches with verb 2 /// (CBKANFIOBCF.txt:4166, route literal :4156) and reads the /// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required; /// DELETE stays for the 2020 client. [HttpPost("api/playerevents/v2/delete/{id:long}")] [HttpDelete("api/playerevents/v2/delete/{id:long}")] public async Task 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(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 UpdateName(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var value = await ReadStringFieldAsync("name", "Name", "value"); if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name"); evt.Title = Clamp(value.Trim(), 128); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(await WrapperAsync(evt)); } [HttpPost("api/playerevents/v2/{eventId:long}/description")] [HttpPut("api/playerevents/v2/{eventId:long}/description")] public async Task UpdateDescription(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty; evt.Description = Clamp(value.Trim(), 2000); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(await WrapperAsync(evt)); } /// PUT api/playerevents/v2/{id}/room — FPPNHCMPAKK sends /// roomId AND an optional subRoomId /// (CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101); dropping the /// latter meant the event never pointed at the sub-room the host picked. /// [HttpPost("api/playerevents/v2/{eventId:long}/room")] [HttpPut("api/playerevents/v2/{eventId:long}/room")] public async Task UpdateRoom(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value"); 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; 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")] [HttpPut("api/playerevents/v2/{eventId:long}/time")] public async Task UpdateTime(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt"); var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt"); if (start is DateTime s) evt.StartsAt = s; if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1); if (start is null && end is null) return BadRequest("missing_time"); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(await WrapperAsync(evt)); } [HttpPost("api/playerevents/v2/{eventId:long}/image")] [HttpPut("api/playerevents/v2/{eventId:long}/image")] public async Task UpdateImage(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty; 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")] [HttpPut("api/playerevents/v2/{eventId:long}/accessibility")] public async Task UpdateAccessibility(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value"); if (value is not int accessibility) return BadRequest("missing_accessibility"); var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; extras.Accessibility = accessibility; await SaveExtrasAsync(evt, extras); return Ok(await WrapperAsync(evt)); } /// PUT api/playerevents/v2/{id}/tags. JHLMDMGELFD does NOT /// send form fields — it serialises List<String> straight into /// a RawJsonForm body, i.e. a BARE JSON ARRAY /// (CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81). The old field /// reader only looked inside JSON objects, so every save stored an empty /// string and silently cleared the tags. The wrapper's /// TagModifyResult.Tags is what the client re-reads afterwards. /// [HttpPost("api/playerevents/v2/{eventId:long}/tags")] [HttpPut("api/playerevents/v2/{eventId:long}/tags")] public async Task UpdateTags(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List(); var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; extras.Tags = tags; await SaveExtrasAsync(evt, extras); return Ok(await WrapperAsync(evt)); } /// PUT api/playerevents/v2/{id}/multiinstance. IEGDKKAKHBD /// sends FOUR form fields — isMultiInstance, /// supportsMultiInstanceRoomChat, defaultBroadcastPermissions, /// canRequestBroadcastPermissions /// (CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161). The handler used /// to look for a field named multiInstance, 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. [HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")] [HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")] public async Task UpdateMultiInstance(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); 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)); } /// PUT api/playerevents/v2/{id}/club. JLHJHGANFDM takes a /// Nullable<Int64> and boxes it into the single form field /// clubId (CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71) — 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. [HttpPost("api/playerevents/v2/{eventId:long}/club")] [HttpPut("api/playerevents/v2/{eventId:long}/club")] public async Task 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 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}")] public async Task Delete(long eventId) { var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); if (ev is null) return NotFound(); if (ev.CreatorPlayerId != Me) return Forbid(); db.PlayerEvents.Remove(ev); // Cascade-clean the responses; SQLite has no FK cascade // configured here so do it manually. 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 ────────────────────────────────────────────────────── public sealed class BulkInviteRequest { public long PlayerEventId { get; set; } public List? InvitedPlayerIds { get; set; } } /// POST /api/playerevents/v1/bulkInvite. Request is /// JsonUtility.ToJson(RecNet.Events.BulkInviteRequest) — /// name-preserved fields PlayerEventId / InvitedPlayerIds /// (2023.06.21 dump.cs:1288323-1288324, body build at /// CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96). Response is /// IDELKAJIOLI {FailedInvites, Result} /// (GAODOHODBDE.txt:203-238) where each failed entry is /// {InvitedPlayerId, Result} (MOJLGDKFECO.txt:203-238) — the /// entries used to be {PlayerId, Error} and deserialised as all /// defaults. Error paths return the same JSON shape rather than a /// NotFound()/Forbid() body the client cannot parse. [HttpPost("api/playerevents/v1/bulkInvite")] public async Task BulkInvite([FromBody] BulkInviteRequest req) { if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0) return Ok(new { FailedInvites = Array.Empty(), Result = 0 }); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); // 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(), Result = 1 }); if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty(), Result = 1 }); var failed = new List(); var sender = Me; var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}."; foreach (var rid in req.InvitedPlayerIds.Distinct()) { var exists = await db.Players.AnyAsync(p => p.Id == rid); if (!exists) { failed.Add(new { InvitedPlayerId = rid, Result = 1 }); continue; } db.Messages.Add(new MessageEntity { SenderPlayerId = sender, RecipientPlayerId = rid, Body = inviteText, }); await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, new { ev.Id, ev.Title, ev.StartsAt, From = sender }); } await db.SaveChangesAsync(); return Ok(new { FailedInvites = failed, Result = 0 }); } /// POST api/playerevents/v1/broadcast — set or clear the /// room instance an event is being broadcast from. JBHPMONELAO posts /// RecNet.Events.BroadcastRoomInstanceRequest /// (CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81) whose JSON keys are /// PlayerEventId and the nullable BroadcastRoomInstanceId /// (EOFEKNIHNNC.txt:215-258), 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. [HttpPost("api/playerevents/v1/broadcast")] public async Task Broadcast() { 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)); } private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id) { var recipients = await db.PlayerEventResponses .Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse) .Select(r => r.PlayerId) .Distinct() .ToListAsync(); foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId)) await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title }); } // ── Report ─────────────────────────────────────────────────────────── public sealed class PlayerEventReportRequest { public int ReportCategory { get; set; } public long PlayerEventId { get; set; } public string? Details { get; set; } } /// POST api/playerevents/v1/report — file a /// moderation report against an event. Wire response is /// KLAMKCBENEA{Success, Message}; returning a bare /// {Reported:true} throws on the strict deserialiser. [HttpPost("api/playerevents/v1/report")] public async Task Report([FromBody] PlayerEventReportRequest req) { var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); if (ev is null) return Ok(new { Success = false, Message = "no_such_event" }); var msg = (req.Details ?? string.Empty).Trim(); db.Reports.Add(new ReportEntity { ReporterPlayerId = Me, TargetPlayerId = ev.CreatorPlayerId, TargetEventId = req.PlayerEventId, Category = req.ReportCategory, Message = msg[..Math.Min(1000, msg.Length)], }); 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 GetOwnedEventAsync(long eventId) => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); private async Task> ReadEventIdsAsync() { var ids = new List(); 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 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 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; } /// 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 Tags), and repeated / /// comma-joined form values. private async Task?> 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(); 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? 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 ElementToStrings(JsonElement array) { var list = new List(); 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 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 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 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 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; } /// Everything the request carries, read once and cached — the body /// stream can only be consumed a single time. private sealed class ParsedBody { public Dictionary Fields { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase); /// Set when the whole body is a bare JSON array — the shape /// the tags PUT sends. public List? RootArray { get; set; } } private const string BodyItemKey = "__playerevent_body"; private async Task 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; } }