From 933c7eb51ccbb07d82627dca0e6b33d456b99846 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Tue, 28 Jul 2026 09:19:35 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20close=20the=20last=20route=20gaps=20?=
=?UTF-8?q?=E2=80=94=20100%=20client=20route+verb=20coverage?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every HTTP request the 2023-03-21 client can issue now reaches a handler under
the verb it actually uses: 522/522, with an empty KnownGaps allowlist. The
coverage test asserts it, so a regression fails the build rather than becoming
a 404 in the wild.
The six routes left after the previous pass all needed storage that did not
exist, so they are done here as one schema change rather than stubbed:
- ClubRolePermissionEntity (ClubId, MembershipType, six bools) backs
PUT club/{id}/permissions/{role}. A club with no stored row falls back to the
derived default policy, so every club that predates the table behaves exactly
as before. Partial writes keep the values they currently resolve to, so a
client that omits a field cannot silently revoke a permission.
- ClubAdditionalImageEntity (ClubId, Slot, ImageName) backs the gallery. The
details envelope now emits real {ImageName, Slot} rows instead of an empty
array, and DELETE clears a slot.
- ClubEntity.MinLevel and ClubEntity.ClubChatEnabled back their setters and are
read back by hasDisabledClubChat and the Club wire shape, which previously
reported hardcoded defaults.
- ChatMessageEntity.ModerationState backs PUT thread/message/{id}/moderate,
gated so an author can retract their own message but only club moderators and
admins can action anyone else's.
- TestCaseEntity.CommentsJson backs the QA comment endpoint, appending rather
than overwriting so a second tester's note cannot erase the first.
Also corrects the Joinability comment on ClubEntity, which documented
1=RequestToJoin / 2=InviteOnly. The real enum is Open=0, InviteOnly=1,
AskToJoin=2 — the inverted reading the requesttojoin handler was already fixed
away from, left behind where it could mislead the next change.
---
.../ClientRouteCoverageTests.cs | 15 +-
.../TestCaseManagementController.cs | 68 +
.../Controllers/Chat/ChatController.cs | 54 +
.../Controllers/Clubs/ClubsController.cs | 250 +-
DorkNet.Server/Data/DorkNetDbContext.cs | 17 +
.../Data/Entities/ChatMessageEntity.cs | 6 +
DorkNet.Server/Data/Entities/ClubEntity.cs | 13 +-
.../Data/Entities/ClubRolePermissionEntity.cs | 65 +
.../Data/Entities/TestCaseEntities.cs | 4 +
...ermissionsGalleryAndModeration.Designer.cs | 3654 +++++++++++++++++
...658_ClubPermissionsGalleryAndModeration.cs | 118 +
.../DorkNetDbContextModelSnapshot.cs | 82 +
12 files changed, 4307 insertions(+), 39 deletions(-)
create mode 100644 DorkNet.Server/Data/Entities/ClubRolePermissionEntity.cs
create mode 100644 DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.Designer.cs
create mode 100644 DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs
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