diff --git a/DorkNet.Server.Tests/ClientRouteCoverageTests.cs b/DorkNet.Server.Tests/ClientRouteCoverageTests.cs
index 39496a9..1103de7 100644
--- a/DorkNet.Server.Tests/ClientRouteCoverageTests.cs
+++ b/DorkNet.Server.Tests/ClientRouteCoverageTests.cs
@@ -20,11 +20,16 @@ namespace DorkNet.Server.Tests;
public sealed class ClientRouteCoverageTests
{
/// Client routes with no handler yet. Each entry is
- /// VERB route exactly as it appears in the inventory file.
- private static readonly HashSet KnownGaps = new(StringComparer.OrdinalIgnoreCase)
- {
- // (populated from the current gap report; remove as they are implemented)
- };
+ /// VERB route exactly as it appears in the inventory file.
+ ///
+ /// This is EMPTY, and that is the point: every request the 2023-03-21
+ /// client can issue reaches a handler under the verb it actually uses. Do
+ /// not add an entry to make a red build green — an entry here is a promise
+ /// that the client gets a 404 or 405 in the wild. Implement the route, or
+ /// if the inventory itself is wrong (a literal that is really a cache key,
+ /// a deeplink, or a base the client concatenates onto), fix the inventory
+ /// so the claim stays honest.
+ private static readonly HashSet KnownGaps = new(StringComparer.OrdinalIgnoreCase);
[Fact]
public void Every_client_route_has_a_handler()
diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs
index c33a086..34b5f99 100644
--- a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs
+++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs
@@ -141,6 +141,74 @@ public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase
return Ok(ToWireCase(tc));
}
+ /// POST api/testcasemanagement/v1/testcase/{id}/comment —
+ /// attach a QA note to a test case.
+ ///
+ /// The client builds the URL by concatenating
+ /// "api/testcasemanagement/v1/testcase/" + id + "/comment"
+ /// (RecNet.Runtime/FLODOBMEJAO.txt:660, :658) and posts it with verb
+ /// ordinal 2 (Move rdx, 2 at :673). The issuing method is
+ /// LDGADANDBIO KNDOKAKBLCC(String, String) (:575) — a status-only
+ /// promise, so the client parses no response DTO and only needs a 2xx.
+ ///
+ /// Comments append to a JSON list on the case rather than overwriting, so
+ /// a second tester's note never erases the first.
+ [HttpPost("api/testcasemanagement/v1/testcase/{id}/comment")]
+ [Authorize]
+ [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
+ public async Task AddComment(string id)
+ {
+ var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
+ if (tc is null) return NotFound();
+
+ var text = (await ReadCommentAsync())?.Trim();
+ if (string.IsNullOrEmpty(text)) return BadRequest("missing comment");
+ if (text.Length > 2000) text = text[..2000];
+
+ var comments = ParseStringList(tc.CommentsJson);
+ comments.Add($"{this.RequireCurrentPlayerId()}|{DateTime.UtcNow:O}|{text.Replace('|', '/')}");
+ tc.CommentsJson = JsonSerializer.Serialize(comments);
+ tc.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return Ok();
+ }
+
+ /// The comment text, from a form field, the query string, a JSON
+ /// object, or a bare JSON string body — the exact spelling is not
+ /// recoverable from the ISIL, so every plausible carrier is accepted.
+ private async Task ReadCommentAsync()
+ {
+ string[] names = ["comment", "Comment", "text", "Text", "body", "Body", "message", "Message"];
+
+ if (Request.HasFormContentType)
+ {
+ var form = await Request.ReadFormAsync();
+ foreach (var n in names)
+ if (form.TryGetValue(n, out var v) && v.Count > 0) return v.ToString();
+ }
+ foreach (var n in names)
+ if (Request.Query.TryGetValue(n, out var q) && q.Count > 0) return q.ToString();
+
+ if (!Request.HasFormContentType)
+ {
+ try
+ {
+ Request.EnableBuffering();
+ Request.Body.Position = 0;
+ using var doc = await JsonDocument.ParseAsync(Request.Body);
+ Request.Body.Position = 0;
+ var root = doc.RootElement;
+ if (root.ValueKind == JsonValueKind.String) return root.GetString();
+ if (root.ValueKind == JsonValueKind.Object)
+ foreach (var n in names)
+ if (root.TryGetProperty(n, out var v) && v.ValueKind == JsonValueKind.String)
+ return v.GetString();
+ }
+ catch (JsonException) { /* no usable body */ }
+ }
+ return null;
+ }
+
// ── Helpers ──────────────────────────────────────────────────────────
private static List ParseIntList(string? json)
diff --git a/DorkNet.Server/Controllers/Chat/ChatController.cs b/DorkNet.Server/Controllers/Chat/ChatController.cs
index 34deafc..54889ba 100644
--- a/DorkNet.Server/Controllers/Chat/ChatController.cs
+++ b/DorkNet.Server/Controllers/Chat/ChatController.cs
@@ -475,6 +475,60 @@ public class ChatController(
/// that client a 405 (and a 415 even if it had used PUT). The
/// response is consumed by an Action<MGGNHLHPHOF>
/// (instr 162) — a BARE integer body, not a wrapper object.
+ /// PUT thread/message/{messageId}/moderate — a moderator
+ /// actions one message.
+ ///
+ /// Binary evidence: DLDKCILCKNA.txt method
+ /// FGLDKEJLAKB<MGGNHLHPHOF> PLBCHNGAEML(RecNet.ChatMessage, MOFGOGFEHNN)
+ /// (:8365); route literal at :8600 with verb constant
+ /// Move rdx, 3 (= PUT) at :8610 and chat host r8 = 8; the
+ /// single form field is moderationState (:8615). The response is the
+ /// same BARE MGGNHLHPHOF integer every other chat mutation returns.
+ ///
+ /// Gated to club/room moderators and admins — a plain thread member must
+ /// not be able to rewrite other people's messages.
+ [HttpPut("/thread/message/{messageId:long}/moderate")]
+ [HttpPost("/thread/message/{messageId:long}/moderate")]
+ public async Task ModerateMessage(long messageId)
+ {
+ var message = await db.ChatMessages.FirstOrDefaultAsync(m => m.Id == messageId);
+ if (message is null) return Ok(ChatThreadNotFound);
+
+ var raw = Request.HasFormContentType
+ ? (await Request.ReadFormAsync())["moderationState"].FirstOrDefault()
+ : Request.Query["moderationState"].FirstOrDefault();
+ if (!int.TryParse(raw, out var state)) return Ok(ChatInvalidArguments);
+
+ // The message author can retract their own message; anyone else needs
+ // moderator standing on the club whose thread it belongs to.
+ var isAuthor = message.SenderPlayerId == Me;
+ if (!isAuthor)
+ {
+ var clubId = ClubIdFromKey(message.ThreadKey);
+ var allowed = false;
+ if (clubId != 0)
+ {
+ var membership = await db.ClubMemberships
+ .FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == Me);
+ allowed = membership is not null &&
+ ClubService.MembershipTypeFromPerms(membership.Permissions)
+ >= ClubService.MembershipTypeModerator;
+ }
+ if (!allowed)
+ {
+ allowed = await db.Players
+ .Where(p => p.Id == Me)
+ .Select(p => p.IsAdmin || p.IsCommunityTeam)
+ .FirstOrDefaultAsync();
+ }
+ if (!allowed) return Ok(ChatMembershipNotFound);
+ }
+
+ message.ModerationState = state;
+ await db.SaveChangesAsync();
+ return Ok(ChatSuccess);
+ }
+
[HttpPut("/thread/{chatThreadId}/rename")]
[HttpPost("/thread/{chatThreadId}/rename")]
public async Task Rename(string chatThreadId,
diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs
index 8639b1a..5f29214 100644
--- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs
+++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs
@@ -394,18 +394,93 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
/// so the body is a BARE JSON boolean — not an object, not a wrapper.
///
/// The answer is the negation of the Club wire field
- /// ClubChatEnabled that emits. That field
- /// has no column on yet and its setter
- /// (PUT club/{id}/clubChatEnabled) is therefore not implemented, so
- /// no club can currently be in the disabled state and false is the
- /// true answer for every club. Both sites must flip together when the
- /// column lands.
+ /// ClubChatEnabled, which is now a real column set by
+ /// PUT club/{id}/clubChatEnabled.
[HttpGet("/club/{clubId:long}/hasDisabledClubChat")]
public async Task ClubHasDisabledChat(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
- return Ok(false);
+ return Content(club.ClubChatEnabled ? "false" : "true", "application/json");
+ }
+
+ /// PUT /club/{id}/clubChatEnabled — toggle the club's chat
+ /// channel (IKMMOCKDKAF.txt:25779). Form field
+ /// clubChatEnabled; responds with the refreshed details
+ /// envelope.
+ [HttpPut("/club/{clubId:long}/clubChatEnabled")]
+ [HttpPost("/club/{clubId:long}/clubChatEnabled")]
+ [Authorize]
+ public async Task SetClubChatEnabled(long clubId)
+ {
+ var value = await ReadBoolFieldAsync("clubChatEnabled", "ClubChatEnabled", "enabled");
+ if (value is not bool enabled) return BadRequest(new { error = "missing_value" });
+ try
+ {
+ var updated = await clubs.ModifyAsync(clubId, Me, c => c.ClubChatEnabled = enabled);
+ if (updated is null) return NotFound();
+ }
+ catch (UnauthorizedAccessException) { return Forbid(); }
+ return await BuildDetailsResponseAsync(clubId);
+ }
+
+ /// PUT /club/{id}/minlevel — minimum account level required
+ /// to join (IKMMOCKDKAF.txt:16561). Form field minLevel;
+ /// responds with the refreshed details envelope.
+ [HttpPut("/club/{clubId:long}/minlevel")]
+ [HttpPost("/club/{clubId:long}/minlevel")]
+ [Authorize]
+ public async Task SetClubMinLevel(long clubId)
+ {
+ var raw = await ReadFieldAsync("minLevel", "MinLevel", "level");
+ if (!int.TryParse(raw, out var minLevel)) return BadRequest(new { error = "missing_value" });
+ minLevel = Math.Clamp(minLevel, 0, 100);
+ try
+ {
+ var updated = await clubs.ModifyAsync(clubId, Me, c => c.MinLevel = minLevel);
+ if (updated is null) return NotFound();
+ }
+ catch (UnauthorizedAccessException) { return Forbid(); }
+ return await BuildDetailsResponseAsync(clubId);
+ }
+
+ /// First value present under any of the given names, from the
+ /// form, the query string, or a JSON body. Null means "not sent".
+ private async Task ReadFieldAsync(params string[] names)
+ {
+ if (Request.HasFormContentType)
+ {
+ var form = await Request.ReadFormAsync();
+ foreach (var n in names)
+ if (form.TryGetValue(n, out var v) && v.Count > 0) return v.ToString();
+ }
+ foreach (var n in names)
+ if (Request.Query.TryGetValue(n, out var q) && q.Count > 0) return q.ToString();
+
+ if (!Request.HasFormContentType)
+ {
+ try
+ {
+ Request.EnableBuffering();
+ Request.Body.Position = 0;
+ using var doc = await JsonDocument.ParseAsync(Request.Body);
+ Request.Body.Position = 0;
+ if (doc.RootElement.ValueKind == JsonValueKind.Object)
+ foreach (var n in names)
+ if (doc.RootElement.TryGetProperty(n, out var v) &&
+ v.ValueKind != JsonValueKind.Null) return v.ToString();
+ }
+ catch (JsonException) { /* no usable body */ }
+ }
+ return null;
+ }
+
+ private async Task ReadBoolFieldAsync(params string[] names)
+ {
+ var raw = await ReadFieldAsync(names);
+ if (raw is null) return null;
+ if (bool.TryParse(raw, out var b)) return b;
+ return raw == "1" ? true : raw == "0" ? false : null;
}
/// GET /club/{id}/details — the central club page
@@ -468,7 +543,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
- return Ok(PermissionsForRole(clubId, role));
+ return Ok(await PermissionsForRoleAsync(clubId, role));
+ }
+
+ /// PUT /club/{id}/permissions/{role} — rewrite what one
+ /// role may do. Route and verb confirmed at
+ /// IKMMOCKDKAF.txt:17685 (route) and :17709
+ /// (Move rdx, 3 = PUT); the six form booleans are
+ /// editDetails, approveMember, createEvent,
+ /// postAnnouncement, editPermissionSettings and
+ /// banUnban (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173,
+ /// :189, :205, :221, :237, :253).
+ ///
+ /// Upserts one row per (club, role). Any field the client omits keeps the
+ /// value it currently resolves to, so a partial write cannot silently
+ /// revoke a permission. Gated on EditPermissionSettings for the caller's
+ /// own role, which is what the client's UI gate checks.
+ [HttpPut("/club/{clubId:long}/permissions/{role:int}")]
+ [HttpPost("/club/{clubId:long}/permissions/{role:int}")]
+ [Authorize]
+ public async Task SetClubPermissions(long clubId, int role)
+ {
+ var club = await clubs.GetByIdAsync(clubId);
+ if (club is null) return NotFound();
+ if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
+
+ var current = await ResolveRolePermissionsAsync(clubId, role);
+ var row = await db.ClubRolePermissions
+ .FirstOrDefaultAsync(p => p.ClubId == clubId && p.MembershipType == role);
+ if (row is null)
+ {
+ row = new ClubRolePermissionEntity { ClubId = clubId, MembershipType = role };
+ db.ClubRolePermissions.Add(row);
+ }
+
+ row.EditDetails = await ReadBoolFieldAsync("editDetails", "EditDetails") ?? current.EditDetails;
+ row.ApproveMember = await ReadBoolFieldAsync("approveMember", "ApproveMember") ?? current.ApproveMember;
+ row.CreateEvent = await ReadBoolFieldAsync("createEvent", "CreateEvent") ?? current.CreateEvent;
+ row.PostAnnouncement = await ReadBoolFieldAsync("postAnnouncement", "PostAnnouncement") ?? current.PostAnnouncement;
+ row.EditPermissionSettings = await ReadBoolFieldAsync("editPermissionSettings", "EditPermissionSettings") ?? current.EditPermissionSettings;
+ row.BanUnban = await ReadBoolFieldAsync("banUnban", "BanUnban") ?? current.BanUnban;
+ row.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+
+ return await BuildDetailsResponseAsync(clubId);
}
// ── Account / created lists ──────────────────────────────────────
@@ -627,14 +745,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
/// POST/PUT /club/{id}/additionalimage/{slot} — the club
/// gallery's per-slot image setter (IKMMOCKDKAF.txt:16214).
///
- /// The request now binds (the client sends form-urlencoded
- /// imageName, which [FromBody] used to reject with 415), but
- /// the slot itself STILL is not persisted: there is no additional-image
- /// table, and the envelope's AdditionalImages is the client's
- /// HIKCHBLAMLP — {ImageName, Slot} — with nowhere to come from. So
- /// the call is accepted and permission-checked, the details envelope comes
- /// back refreshed, and the gallery renders blank until a
- /// ClubAdditionalImageEntity lands.
+ /// The slot is persisted as a row
+ /// and read back by the details envelope's AdditionalImages
+ /// (HIKCHBLAMLP = {ImageName, Slot}), so the gallery renders what
+ /// the creator set. An empty imageName clears the slot, same as the
+ /// DELETE twin.
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
@@ -643,6 +758,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
+
+ var imageName = (req?.ImageName ?? string.Empty).Trim();
+ var row = await db.ClubAdditionalImages
+ .FirstOrDefaultAsync(i => i.ClubId == clubId && i.Slot == slot);
+ if (imageName.Length == 0)
+ {
+ if (row is not null) db.ClubAdditionalImages.Remove(row);
+ }
+ else if (row is null)
+ {
+ db.ClubAdditionalImages.Add(new ClubAdditionalImageEntity
+ {
+ ClubId = clubId,
+ Slot = slot,
+ ImageName = imageName.Length > 256 ? imageName[..256] : imageName,
+ });
+ }
+ else
+ {
+ row.ImageName = imageName.Length > 256 ? imageName[..256] : imageName;
+ row.UpdatedAt = DateTime.UtcNow;
+ }
+ await db.SaveChangesAsync();
+ return await BuildDetailsResponseAsync(clubId);
+ }
+
+ /// DELETE /club/{id}/additionalimage/{slot} — clear one
+ /// gallery slot. Same permission gate as the setter; idempotent, so
+ /// clearing an empty slot is a success.
+ [HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")]
+ [Authorize]
+ public async Task ClubAdditionalImageDelete(long clubId, int slot)
+ {
+ var club = await clubs.GetByIdAsync(clubId);
+ if (club is null) return NotFound();
+ if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
+
+ var row = await db.ClubAdditionalImages
+ .FirstOrDefaultAsync(i => i.ClubId == clubId && i.Slot == slot);
+ if (row is not null)
+ {
+ db.ClubAdditionalImages.Remove(row);
+ await db.SaveChangesAsync();
+ }
return await BuildDetailsResponseAsync(clubId);
}
@@ -1456,10 +1615,14 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
// gallery slots get a table; see ClubAdditionalImage.
Club = ToWireClub(club, memberCount),
CustomTags = await CustomTagsAsync(clubId),
- AdditionalImages = Array.Empty