mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: implement the client routes that had no handler
Sixteen agents, one controller each, over every route the coverage test reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%), up from 90.8%. Each handler was written against binary evidence rather than the audit's prose: verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from the issuing method's return type, request shape from whether the client uses form fields, a raw JsonUtility body, or query params. Agents were told to skip rather than stub, and several did. New surface worth noting: - Link host: actionlink create/get/consume and datalink get/put, previously unrouted entirely. - Club roster reads: members, banned, requests, requests/search, members/search, each with the client's real sort/paging params and the wire row shape its reader expects (the banned row has no MembershipType field, so it isn't sent). - Home clubhouse set/clear, club disband (soft, via the existing State column), and DELETE mainimage as its own action rather than a verb alias — the client parses the details envelope from it, so it is not status-only. - Club chat threads, comment CRUD, keepsake collect/delete, progression-event xp boosts, image report and cheered/bulk, iOS StoreKit product reporting. The six routes still missing all need storage that does not exist yet (per-role club permissions, club additional-image slots, MinLevel, ClubChatEnabled). Those are left for a single schema change rather than faked. Also excludes DELETE /account/me from the endpoint contract probe. That route is now a real, correctly-guarded account purge, and the probe walks every route with one test player — so calling it destroyed the probe's own session and turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
@@ -185,19 +185,41 @@ public class ClubService(DorkNetDbContext db)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-player settings key holding the explicitly chosen home club.
|
||||
/// <see cref="PlayerSettingEntity"/> is the server's general-purpose
|
||||
/// per-player key/value bag (the same store <c>MatchPlayerController</c>
|
||||
/// and <c>RoomsController</c> use for stateful scalars), so the 2023
|
||||
/// client's PUT/DELETE <c>club/home/me</c> pair persists here rather
|
||||
/// than needing a new column on <see cref="PlayerEntity"/>.
|
||||
/// </summary>
|
||||
public const string HomeClubSettingKey = "club.home";
|
||||
|
||||
/// <summary>
|
||||
/// Composite home-tab payload for <c>GET /club/home/me</c>. The
|
||||
/// watch's deserialiser for this endpoint expects a single
|
||||
/// <c>RecNet.Club</c> object (<c>typeof(System.Action`1<PLILLKHMNDA>)</c>
|
||||
/// per ISIL) — typically the player's "home club" (set via
|
||||
/// <c>SetMyHomeClub</c>) rendered as the headline card. We pick the
|
||||
/// first club the player is a member of, ordered by membership
|
||||
/// recency. Returns null when the player has no club memberships
|
||||
/// — the controller turns that into <see cref="Microsoft.AspNetCore.Mvc.NoContentResult"/>
|
||||
/// so the watch knows to render the "join a club" empty state.
|
||||
/// <c>SetMyHomeClub</c>) rendered as the headline card. An explicit
|
||||
/// pick made through <see cref="SetHomeClubAsync"/> always wins; when
|
||||
/// nothing is stored (or the stored club has since been disbanded) we
|
||||
/// fall back to the most recently joined club so the tab is never
|
||||
/// blank for an existing member. Returns null when the player has no
|
||||
/// club memberships at all — the controller turns that into the
|
||||
/// ClubId=0 sentinel the watch renders as "no home club".
|
||||
/// </summary>
|
||||
public async Task<ClubEntity?> HomeClubAsync(long playerId)
|
||||
{
|
||||
var stored = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey)
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
if (long.TryParse(stored, out var storedId) && storedId > 0)
|
||||
{
|
||||
var chosen = await db.Clubs.FirstOrDefaultAsync(c => c.Id == storedId && c.State == 0);
|
||||
if (chosen is not null) return chosen;
|
||||
}
|
||||
|
||||
// Most-recent-joined club wins. Owners are members too (the
|
||||
// /create flow inserts an owner-permissions=127 membership
|
||||
// row), so a brand-new club creator sees their fresh club here.
|
||||
@@ -209,6 +231,112 @@ public class ClubService(DorkNetDbContext db)
|
||||
return pick;
|
||||
}
|
||||
|
||||
/// <summary>Persist the player's explicit home-club pick. Returns false
|
||||
/// when the club id doesn't resolve to a live club so the caller can 404
|
||||
/// instead of storing a dangling id.</summary>
|
||||
public async Task<bool> SetHomeClubAsync(long playerId, long clubId)
|
||||
{
|
||||
if (!await db.Clubs.AnyAsync(c => c.Id == clubId && c.State == 0)) return false;
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = HomeClubSettingKey,
|
||||
Value = clubId.ToString(),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = clubId.ToString();
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Drop the player's explicit home-club pick. Idempotent —
|
||||
/// missing row is a no-op, matching the client's fire-and-forget
|
||||
/// "Remove Home Clubhouse Room" call.</summary>
|
||||
public async Task ClearHomeClubAsync(long playerId)
|
||||
{
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
|
||||
if (row is null) return;
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disband a club — the client's "Delete Club" action. Owner-only
|
||||
/// (unlike <see cref="ModifyAsync"/>, which co-owners and moderators
|
||||
/// can drive), and a soft delete: State is stamped with the client's
|
||||
/// <c>IACDINKNHKB.MarkedForDelete</c> = 1000 so every read path here
|
||||
/// (all of which filter <c>State == 0</c>) stops surfacing it while
|
||||
/// announcements / memberships stay intact for moderation review.
|
||||
/// Returns null when the club doesn't exist; throws
|
||||
/// <see cref="UnauthorizedAccessException"/> when the caller isn't the
|
||||
/// creator.
|
||||
/// </summary>
|
||||
public async Task<ClubEntity?> DisbandAsync(long clubId, long callerId)
|
||||
{
|
||||
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
|
||||
if (club is null) return null;
|
||||
if (club.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
|
||||
club.State = 1000; // IACDINKNHKB.MarkedForDelete
|
||||
club.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return club;
|
||||
}
|
||||
|
||||
/// <summary>Every announcement across the clubs the player is a real
|
||||
/// member of (pending invites/requests = 128 and ban markers = 256 are
|
||||
/// excluded — they must not leak a club's board). Backs the flat
|
||||
/// <c>GET /announcements/mine</c> list, which is a different wire shape
|
||||
/// from the per-club unread rollup.</summary>
|
||||
public async Task<List<ClubAnnouncementEntity>> AnnouncementsForMemberClubsAsync(
|
||||
long playerId, int take = 100)
|
||||
{
|
||||
var clubIds = await db.ClubMemberships
|
||||
.Where(m => m.PlayerId == playerId && (m.Permissions & 384) == 0)
|
||||
.Select(m => m.ClubId)
|
||||
.ToListAsync();
|
||||
return await AnnouncementsAcrossClubsAsync(clubIds, take);
|
||||
}
|
||||
|
||||
/// <summary>Every announcement across the clubs the player subscribes
|
||||
/// to. Backs the flat <c>GET /announcements/subscription/mine</c>
|
||||
/// list.</summary>
|
||||
public async Task<List<ClubAnnouncementEntity>> AnnouncementsForSubscribedClubsAsync(
|
||||
long playerId, int take = 100)
|
||||
{
|
||||
var clubIds = await db.ClubSubscriptions
|
||||
.Where(s => s.PlayerId == playerId)
|
||||
.Select(s => s.ClubId)
|
||||
.ToListAsync();
|
||||
return await AnnouncementsAcrossClubsAsync(clubIds, take);
|
||||
}
|
||||
|
||||
private async Task<List<ClubAnnouncementEntity>> AnnouncementsAcrossClubsAsync(
|
||||
List<long> clubIds, int take)
|
||||
{
|
||||
if (clubIds.Count == 0) return new List<ClubAnnouncementEntity>();
|
||||
// Disbanded clubs keep their rows but must not surface on the
|
||||
// aggregated feed, so the club set is re-filtered on State.
|
||||
var live = await db.Clubs
|
||||
.Where(c => clubIds.Contains(c.Id) && c.State == 0)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
if (live.Count == 0) return new List<ClubAnnouncementEntity>();
|
||||
return await db.ClubAnnouncements
|
||||
.Where(a => live.Contains(a.ClubId))
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ThenByDescending(a => a.Id)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a read-marker so subsequent <see cref="UnreadDirectAsync"/>
|
||||
/// / <see cref="UnreadSubscriptionAsync"/> calls skip the
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The single account-erasure cascade used by every "remove this player"
|
||||
/// path in the server: the admin SPA's
|
||||
/// <c>DELETE api/admin/v1/players/{id}</c> and the 2023 client's
|
||||
/// <c>DELETE account/me</c> ("delete local account").
|
||||
///
|
||||
/// Personal rows are deleted outright; authored durable content (rooms,
|
||||
/// clubs, inventions, playlists, chat threads …) is reassigned to the
|
||||
/// system account so other players' worlds don't lose their contents when
|
||||
/// one author leaves. Kept as a static helper — not a DI service — so both
|
||||
/// call sites share one implementation without either controller having to
|
||||
/// grow a constructor dependency.
|
||||
///
|
||||
/// Callers are responsible for the surrounding transaction, for removing
|
||||
/// the <c>Players</c> row itself, and for kicking the live session.
|
||||
/// </summary>
|
||||
public static class PlayerAccountPurge
|
||||
{
|
||||
/// <summary>Delete/reassign every row that references
|
||||
/// <paramref name="playerId"/>. Returns a one-line audit summary.</summary>
|
||||
public static async Task<string> PurgeAsync(
|
||||
DorkNetDbContext db, long playerId, long systemPlayerId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var deleted = 0;
|
||||
var reassigned = 0;
|
||||
|
||||
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
|
||||
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
reassigned += await db.ChatThreads
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
|
||||
|
||||
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
|
||||
|
||||
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
reassigned += await db.Clubs
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||
.SetProperty(x => x.UpdatedAt, now));
|
||||
reassigned += await db.ClubAnnouncements
|
||||
.Where(x => x.AuthorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
|
||||
|
||||
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
|
||||
reassigned += await db.RoomBans
|
||||
.Where(x => x.BannedByPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
|
||||
reassigned += await db.RoomRoles
|
||||
.Where(x => x.GrantedByPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
|
||||
reassigned += await db.Rooms
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||
.SetProperty(x => x.UpdatedAt, now));
|
||||
reassigned += await db.RoomDataBlobs
|
||||
.Where(x => x.UploadedByPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
|
||||
reassigned += await db.RoomKeys
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
|
||||
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
|
||||
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
reassigned += await db.Playlists
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
|
||||
|
||||
deleted += await db.PlayerEventResponses
|
||||
.Where(x => x.PlayerId == playerId
|
||||
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
|
||||
.ExecuteDeleteAsync();
|
||||
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
|
||||
|
||||
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
var privateInstanceIds = await db.PrivateInstances
|
||||
.Where(x => x.OwnerPlayerId == playerId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync();
|
||||
if (privateInstanceIds.Count > 0)
|
||||
{
|
||||
deleted += await db.PrivateInstanceInvitees
|
||||
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
|
||||
.ExecuteDeleteAsync();
|
||||
deleted += await db.PrivateInstances
|
||||
.Where(x => privateInstanceIds.Contains(x.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
deleted += await db.GiftPackages
|
||||
.Where(x => x.RecipientPlayerId == playerId)
|
||||
.ExecuteDeleteAsync();
|
||||
if (playerId <= int.MaxValue)
|
||||
{
|
||||
reassigned += await db.GiftPackages
|
||||
.Where(x => x.FromPlayerId == (int)playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
|
||||
}
|
||||
|
||||
reassigned += await db.Inventions
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||
.SetProperty(x => x.UpdatedAt, now));
|
||||
reassigned += await db.Photos
|
||||
.Where(x => x.UploaderPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
|
||||
.SetProperty(x => x.DeletedAt, now)
|
||||
.SetProperty(x => x.IsPublic, false));
|
||||
|
||||
reassigned += await db.SignupCodes
|
||||
.Where(x => x.RedeemedByPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
|
||||
|
||||
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user