feat: photo upload at level 3 + report a player (nudity avatar / chat insult)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m58s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s

Photo upload:
- Lower the custom profile-photo gate from level 25 to level 3 (client const +
  i18n hint + server gate in ProfileService.Update). The level-25 "Expert" title
  is unrelated and unchanged.

Report a player:
- New ReportReason type + service.reportUser(targetId, reason, details?).
- Report entry points: a "گزارش تخلف" button + reason picker (nudity / insult /
  other) in the public-profile modal, and a flag button in the chat header
  (reports the peer for an insulting chat) with a confirmation toast.
- Mock records reports to localStorage; SignalR POSTs /api/report.
- Server: POST /api/report → ProfileService.ReportUser stores the report in the
  write-only ledger (kind="report", ref="{targetId}|{reason}|{details}") so no
  schema change is needed (server uses EnsureCreated, not migrations).
- i18n: report.* keys (fa + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 19:12:02 +03:30
parent 8033023a1f
commit 6641741669
10 changed files with 165 additions and 8 deletions
@@ -54,13 +54,28 @@ public class ProfileService
await _db.SaveChangesAsync();
}
/// <summary>
/// Record a moderation report (inappropriate avatar / insulting chat). Stored
/// in the write-only ledger as kind="report" so no schema change is needed;
/// Ref encodes "{targetId}|{reason}|{details}".
/// </summary>
public async Task ReportUser(string reporterUid, string targetId, string? reason, string? details)
{
if (string.IsNullOrWhiteSpace(targetId)) return;
var safeReason = reason is "nudity" or "insult" or "other" ? reason : "other";
var safeDetails = (details ?? "").Replace("\n", " ").Trim();
var @ref = $"{targetId}|{safeReason}|{safeDetails}";
if (@ref.Length > 480) @ref = @ref[..480];
await Ledger(reporterUid, "report", 0, @ref);
}
public async Task<ProfileDto> Update(string uid, JsonElement patch)
{
var p = await GetOrCreate(uid, null);
if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!;
// Custom photo upload is gated behind level 25.
if (p.Level >= 25 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
// Custom photo upload is gated behind level 3.
if (p.Level >= 3 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
if (patch.TryGetProperty("title", out var ti) && ti.ValueKind == JsonValueKind.String) p.Title = ti.GetString();
if (patch.TryGetProperty("cardFront", out var cf) && cf.ValueKind == JsonValueKind.String) p.CardFront = cf.GetString()!;
if (patch.TryGetProperty("cardBack", out var cb) && cb.ValueKind == JsonValueKind.String) p.CardBack = cb.GetString()!;
+8
View File
@@ -181,6 +181,13 @@ app.MapGet("/api/profile/{id}/public", async (string id, ClaimsPrincipal u, Soci
return p != null ? Results.Json(p, JsonOpts.Default) : Results.NotFound();
}).RequireAuthorization();
// Report a player (inappropriate avatar / insulting chat).
app.MapPost("/api/report", async (ClaimsPrincipal u, ProfileService svc, ReportReq req) =>
{
await svc.ReportUser(Uid(u), req.TargetId, req.Reason, req.Details);
return Results.Json(new { ok = true }, JsonOpts.Default);
}).RequireAuthorization();
// Discover players (find-friends hub): search by name + suggestions.
app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialService s) =>
Results.Json(await s.SearchPlayers(Uid(u), q ?? ""), JsonOpts.Default)).RequireAuthorization();
@@ -308,3 +315,4 @@ record IabVerifyReq(string Store, string ProductId, string Token);
record QueryReq(string? Query = null, string? UserId = null);
record IdReq(string Id);
record SendReq(string PeerId, string Text);
record ReportReq(string TargetId, string? Reason, string? Details);