using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; 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/* — create, list, RSVP for /// player-scheduled gatherings. /// [ApiController] [Authorize] public class PlayerEventsController( DorkNetDbContext db, NotificationService notifications) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); // ── Browse (v2) ────────────────────────────────────────────────────── /// GET api/playerevents/v2 — 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). [AllowAnonymous] [HttpGet("api/playerevents/v2")] 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(rows.Select(ToWire)); } [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(ToWire(ev)); } [AllowAnonymous] [HttpGet("api/playerevents/v1/search")] [HttpGet("api/playerevents/v1/searchlive")] public async Task Search( [FromQuery] string? q = null, [FromQuery] string? query = 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); if (needle.Length > 0) { rows = rows.Where(e => e.Title.ToLower().Contains(needle) || e.Description.ToLower().Contains(needle)); } var result = await rows .OrderBy(e => e.StartsAt) .Skip(skip) .Take(take) .ToListAsync(); return Ok(result.Select(ToWire)); } [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(rows.Select(ToWire)); } [AllowAnonymous] [HttpGet("api/playerevents/v1/club/{clubId:long}")] public async Task ForClub(long clubId) { var memberIds = await db.ClubMemberships .Where(m => m.ClubId == clubId) .Select(m => m.PlayerId) .ToListAsync(); if (memberIds.Count == 0) return Ok(Array.Empty()); var roomIds = await db.Rooms .Where(r => memberIds.Contains(r.CreatorPlayerId)) .Select(r => r.Id) .ToListAsync(); if (roomIds.Count == 0) return Ok(Array.Empty()); 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)); } [AllowAnonymous] [HttpGet("api/playerevents/v1/clubs")] public async Task 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()); 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)); } public sealed class EventBulkRequest { public List? PlayerEventIds { get; set; } public List? EventIds { get; set; } public List? Ids { get; set; } } [AllowAnonymous] [HttpPost("api/playerevents/v1/bulk")] [HttpGet("api/playerevents/v1/bulk")] public async Task Bulk([FromBody] EventBulkRequest? body) { var ids = await ReadEventIdsAsync(body); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await db.PlayerEvents .Where(e => ids.Contains(e.Id)) .ToListAsync(); return Ok(rows.Select(ToWire)); } [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" }, }); /// GET api/playerevents/v1/all — the watch's /// LocalPlayerEventInfo fetch (caller's created + RSVP'd /// events). Response keys (both required, via /// Util.GetObjectListKey): Created, Responses. [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() }); 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 }); } [AllowAnonymous] [HttpGet("api/playerevents/v1/all/{accountId:long}")] public async Task AllPlayerEventsForAccount(long accountId) { var created = await db.PlayerEvents .Where(e => e.CreatorPlayerId == accountId) .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 == accountId) .Select(r => new { r.Id, r.EventId, r.Response, r.CreatedAt, }) .ToListAsync(); return Ok(new { Created = created, Responses = responses }); } /// POST api/playerevents/v1/{eventId}/responses — /// alternate RSVP route the watch uses for the response-list /// flow (older / sub-room view). Body is the same RsvpRequest /// as v1/{id}/rsvp; behaves identically. [HttpPost("api/playerevents/v1/{eventId:long}/responses")] public Task RespondList(long eventId, [FromBody] RsvpRequest body) => Rsvp(eventId, body); /// Wire shape verified against /// Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815: /// PascalCase keys PlayerEventId, Name, Description, /// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId, /// ImageName, Accessibility. Server-side we store these /// under different names (Title, StartsAt, EndsAt, Capacity) — /// remap explicitly here so the watch's /// PlayerEvent.Deserialize picks them up. private static object ToWire(PlayerEventEntity ev) => new { 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 }; 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(new { ev.Id, ev.Title, ev.Description, ev.RoomId, ev.StartsAt, ev.EndsAt, ev.Capacity, }); } 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 }); return Ok(new { row.EventId, row.PlayerId, row.Response }); } /// POST api/playerevents/v1/respond — set the /// caller's RSVP state for an event. Response is the /// PlayerEventResponseType enum (0=Going, 1=Maybe, 2=Pass). /// Idempotent — re-responding overwrites. /// /// Wire return is a BARE int (DGOPHENCPOC enum value: /// 0=Success, plus various error codes). Returning an object /// here throws InvalidCastException on the 2020.12 deserialiser /// — verified at recroom-2020-client-response-contracts.md /// under the playerevents/v1/respond section. [HttpPost("api/playerevents/v1/respond")] public async Task RespondForm( [FromForm(Name = "EventId")] long? eventId, [FromForm(Name = "Response")] int response = 0) { if (eventId is not long evt || evt <= 0) return Ok(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 Ok(0 /*Success*/); } /// POST api/playerevents/v1/deleteResponse — drop /// the caller's RSVP row. Same bare-int wire shape as /// . [HttpPost("api/playerevents/v1/deleteResponse")] public async Task DeleteResponseForm([FromForm(Name = "EventId")] long? eventId) { if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/); await db.PlayerEventResponses .Where(r => r.PlayerId == Me && r.EventId == evt) .ExecuteDeleteAsync(); return Ok(0 /*Success*/); } /// DELETE api/playerevents/v2/delete/{id} — hard /// delete an event the caller created. [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(); db.PlayerEvents.Remove(evt); await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); await db.SaveChangesAsync(); return Ok(new { success = true, error = "" }); } [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 = value.Trim()[..Math.Min(value.Trim().Length, 128)]; evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(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 = value.Trim()[..Math.Min(value.Trim().Length, 2000)]; evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(evt)); } [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; evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(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(ToWire(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; await SetEventSettingAsync(evt, "image", value.Trim()); return Ok(ToWire(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"); await SetEventSettingAsync(evt, "accessibility", accessibility.ToString()); return Ok(ToWire(evt)); } [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 value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty; await SetEventSettingAsync(evt, "tags", value.Trim()); return Ok(ToWire(evt)); } [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 value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value"); await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false"); return Ok(ToWire(evt)); } [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 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)); } [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(); return Ok(); } // ── Bulk-invite (Phase 8) ──────────────────────────────────────────── public sealed class BulkInviteRequest { public long PlayerEventId { get; set; } public List? InvitedPlayerIds { get; set; } } /// POST /api/playerevents/v1/bulkInvite — wire /// type per agent ISIL extraction: BulkInviteRequest{PlayerEventId, /// InvitedPlayerIds:List<int>}; response /// BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}. /// We insert one MessageEntity per recipient so the watch's inbox /// shows the invite, and push a per-recipient notification. [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); if (ev is null) return NotFound(); if (ev.CreatorPlayerId != Me) return Forbid(); 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 { PlayerId = rid, Error = "unknown player" }); 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 }); } public sealed class BroadcastRequest { public long PlayerEventId { get; set; } public string? Message { get; set; } } [HttpPost("api/playerevents/v1/broadcast")] public async Task Broadcast([FromBody] BroadcastRequest req) { 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) .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 }); } // ── Report (Phase 8) ───────────────────────────────────────────────── public sealed class PlayerEventReportRequest { public int ReportCategory { get; set; } public long PlayerEventId { get; set; } public string? Details { get; set; } } private async Task GetOwnedEventAsync(long eventId) => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); private async Task> ReadEventIdsAsync(EventBulkRequest? body) { var ids = new List(); if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds); if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds); if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids); foreach (var value in Request.Query.SelectMany(q => q.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); 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]) foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) if (long.TryParse(part, out var id) && id > 0) ids.Add(id); } return ids.Distinct().Take(200).ToList(); } private async Task 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 ReadLongFieldAsync(params string[] names) { var value = await ReadStringFieldAsync(names); return long.TryParse(value, out var parsed) ? parsed : null; } private async Task ReadIntFieldAsync(params string[] names) { var value = await ReadStringFieldAsync(names); return int.TryParse(value, out var parsed) ? parsed : null; } private async Task ReadBoolFieldAsync(params string[] names) { var value = await ReadStringFieldAsync(names); return bool.TryParse(value, out var parsed) ? parsed : null; } private async Task ReadDateFieldAsync(params string[] names) { var value = await ReadStringFieldAsync(names); return DateTime.TryParse(value, out var parsed) ? parsed : null; } private async Task> ReadRequestFieldsAsync() { const string itemKey = "__playerevent_fields"; if (HttpContext.Items.TryGetValue(itemKey, out var cached) && cached is Dictionary existing) { return existing; } var fields = new Dictionary(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(); } /// 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 }); } }