feat: UNO-style table, social hub, cosmetics, speed mode, store IAB

Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+7 -3
View File
@@ -17,8 +17,12 @@ public sealed class Player
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
public sealed class GameManager
{
// Real players get priority: wait this long for humans before bots fill in.
private const int QueueWaitMs = 9000;
// Real players get priority: wait ~15s for humans before bots fill in. The
// exact wait is randomized per ticket (1218s) so the queue doesn't feel
// robotically identical every time.
private const int QueueWaitMinMs = 12000;
private const int QueueWaitMaxMs = 18000;
private int NextQueueWaitMs() => _rng.Next(QueueWaitMinMs, QueueWaitMaxMs + 1);
private static readonly string[] BotNames =
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
@@ -53,7 +57,7 @@ public sealed class GameManager
lock (_mmLock)
{
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
var timer = new Timer(_ => FlushTicket(p.UserId), null, NextQueueWaitMs(), Timeout.Infinite);
_waiting.Add((p, timer));
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
new MatchmakingStateDto("searching", _waiting.Count, null));
+4 -1
View File
@@ -28,7 +28,10 @@ public sealed class GameRoom : IDisposable
private const int AiPlayMs = 800;
private const int TrickPauseMs = 1100;
private const int RoundPauseMs = 2500;
public const int TurnMs = 20000;
// Higher leagues (bigger stake) give LESS time to act — players think faster.
// Starter/free → 15s, Pro (≥500) → 10s, Expert (≥1000) → 7s. Mirrors the
// client's turnMsForStake() so the turn clock matches in either mode.
private int TurnMs => Stake >= 1000 ? 7000 : Stake >= 500 ? 10000 : 15000;
private readonly object _lock = new();
private readonly IHubContext<GameHub> _hub;
@@ -0,0 +1,121 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace Hokm.Server.Payments;
/// <summary>
/// Config for store in-app billing verification. Fill these from the Cafe Bazaar
/// (pardakht) and Myket developer panels. Bound from the "Iab" config section /
/// <c>Iab__*</c> env vars.
/// </summary>
public sealed class IabOptions
{
/// <summary>Android package name registered in the store panels.</summary>
public string PackageName { get; set; } = "com.bargevasat.hokm";
// ── Cafe Bazaar (pardakht dev API, OAuth refresh-token flow) ──
public string BazaarClientId { get; set; } = "";
public string BazaarClientSecret { get; set; } = "";
public string BazaarRefreshToken { get; set; } = "";
// ── Myket (developer validation API) ──
public string MyketAccessToken { get; set; } = "";
/// <summary>
/// DEV ONLY. When true, purchases are credited WITHOUT remote verification
/// (use for local testing before you have store credentials). NEVER enable in
/// production — it lets a forged token mint coins.
/// </summary>
public bool AllowUnverified { get; set; } = false;
}
/// <summary>
/// Verifies a store purchase token (Cafe Bazaar / Myket) server-to-server before
/// coins are credited. Endpoints are config-driven; confirm the exact URLs against
/// your store panel — the request/response shapes mirror Google Play's IAB API.
/// </summary>
public sealed class IabService
{
private static readonly HttpClient Http = new();
private readonly IabOptions _opts;
private readonly ILogger<IabService> _log;
public IabService(IabOptions opts, ILogger<IabService> log)
{
_opts = opts;
_log = log;
}
public async Task<bool> Verify(string store, string productId, string token)
{
if (string.IsNullOrWhiteSpace(token)) return _opts.AllowUnverified;
store = (store ?? "").Trim().ToLowerInvariant();
try
{
return store switch
{
"bazaar" or "cafebazaar" => await VerifyBazaar(productId, token),
"myket" => await VerifyMyket(productId, token),
_ => _opts.AllowUnverified,
};
}
catch (Exception ex)
{
_log.LogWarning(ex, "IAB verify failed for store {Store} product {Product}", store, productId);
return _opts.AllowUnverified;
}
}
/// <summary>
/// Cafe Bazaar: exchange the refresh token for an access token, then validate
/// the in-app purchase. See https://pardakht.cafebazaar.ir/devapi/v2/.
/// </summary>
private async Task<bool> VerifyBazaar(string productId, string token)
{
if (string.IsNullOrWhiteSpace(_opts.BazaarRefreshToken)) return _opts.AllowUnverified;
// 1) refresh_token → access_token
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["client_id"] = _opts.BazaarClientId,
["client_secret"] = _opts.BazaarClientSecret,
["refresh_token"] = _opts.BazaarRefreshToken,
});
var tokenResp = await Http.PostAsync("https://pardakht.cafebazaar.ir/devapi/v2/auth/token/", form);
if (!tokenResp.IsSuccessStatusCode) return false;
using var tokenDoc = JsonDocument.Parse(await tokenResp.Content.ReadAsStringAsync());
if (!tokenDoc.RootElement.TryGetProperty("access_token", out var at)) return false;
var access = at.GetString();
// 2) validate the purchase
var url = $"https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{_opts.PackageName}/inapp/{Uri.EscapeDataString(productId)}/purchases/{Uri.EscapeDataString(token)}/?access_token={access}";
var vResp = await Http.GetAsync(url);
if (!vResp.IsSuccessStatusCode) return false;
using var vDoc = JsonDocument.Parse(await vResp.Content.ReadAsStringAsync());
// purchaseState: 0 = purchased (1 = refunded/cancelled). Absent ⇒ a 200 body
// is itself proof of a valid purchase.
if (vDoc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number)
return ps.GetInt32() == 0;
return true;
}
/// <summary>
/// Myket: validate via the developer API (mirrors Google Play). The access
/// token comes from the Myket developer panel.
/// </summary>
private async Task<bool> VerifyMyket(string productId, string token)
{
if (string.IsNullOrWhiteSpace(_opts.MyketAccessToken)) return _opts.AllowUnverified;
var url = $"https://developer.myket.ir/api/applications/{_opts.PackageName}/purchases/products/{Uri.EscapeDataString(productId)}/tokens/{Uri.EscapeDataString(token)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Add("X-Access-Token", _opts.MyketAccessToken);
var resp = await Http.SendAsync(req);
if (!resp.IsSuccessStatusCode) return false;
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number)
return ps.GetInt32() == 0;
return true;
}
}
@@ -18,6 +18,15 @@ public class StatsDto
public int RoundsWon { get; set; }
}
/// <summary>Optional social-media handles a player chooses to share.</summary>
public class SocialLinksDto
{
public string? Instagram { get; set; }
public string? Telegram { get; set; }
public string? X { get; set; }
public string? Youtube { get; set; }
}
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
public class ProfileDto
{
@@ -48,11 +57,42 @@ public class ProfileDto
public List<string> Unlocked { get; set; } = new();
public long CreatedAt { get; set; }
// social
public string Gender { get; set; } = ""; // "" | male | female | other
public SocialLinksDto Socials { get; set; } = new();
public string SocialsVisibility { get; set; } = "public"; // public | friends | hidden
// daily reward streak
public int DailyDay { get; set; } = 1;
public string? DailyLastClaimed { get; set; } // yyyy-MM-dd
}
/// <summary>
/// Public-facing view of another player (no coins/phone/email). Mirrors the
/// client <c>PublicProfile</c>. Returned by <c>GET /api/profile/{id}/public</c>.
/// </summary>
public class PublicProfileDto
{
public string Id { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public string Plan { get; set; } = "free";
public string? Title { get; set; }
public int Level { get; set; } = 1;
public int Rating { get; set; } = 1000;
public StatsDto Stats { get; set; } = new();
public Dictionary<string, int> Achievements { get; set; } = new();
public List<string> Unlocked { get; set; } = new();
public long CreatedAt { get; set; }
public string Gender { get; set; } = "";
/// <summary>Only populated when the viewer is allowed to see them (public / friend / self).</summary>
public SocialLinksDto? Socials { get; set; }
public bool IsFriend { get; set; }
public bool IsYou { get; set; }
public bool RequestSent { get; set; }
}
public class MatchSummaryDto
{
public bool Ranked { get; set; }
@@ -64,6 +64,11 @@ public class ProfileService
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()!;
// social
if (patch.TryGetProperty("gender", out var ge) && ge.ValueKind == JsonValueKind.String) p.Gender = ge.GetString()!;
if (patch.TryGetProperty("socialsVisibility", out var sv) && sv.ValueKind == JsonValueKind.String) p.SocialsVisibility = sv.GetString()!;
if (patch.TryGetProperty("socials", out var so) && so.ValueKind == JsonValueKind.Object)
p.Socials = JsonSerializer.Deserialize<SocialLinksDto>(so.GetRawText(), JsonOpts.Default) ?? p.Socials;
return await Save(p);
}
@@ -144,6 +149,7 @@ public class ProfileService
"cardback" => p.OwnedCardBacks,
"reactionpack" => p.OwnedReactionPacks,
"stickerpack" => p.OwnedStickerPacks,
"title" => p.OwnedTitles,
_ => null,
};
if (list == null) return (false, null, "bad_kind");
@@ -158,7 +164,8 @@ public class ProfileService
/* ----------------------------- daily ------------------------------ */
private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 500, 1000 };
// Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly.
private static readonly int[] DailyRewards = { 300, 500, 750, 1000, 1500, 2500, 7500 };
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
+27 -6
View File
@@ -41,6 +41,11 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df
builder.Services.AddSingleton(zp);
builder.Services.AddSingleton<ZarinpalService>();
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
builder.Services.AddSingleton(iab);
builder.Services.AddSingleton<IabService>();
// --- SignalR (camelCase to match the TS client) ---
builder.Services
.AddSignalR()
@@ -148,6 +153,19 @@ app.MapPut("/api/profile", async (ClaimsPrincipal u, ProfileService svc, JsonEle
Results.Json(await svc.Update(Uid(u), patch), JsonOpts.Default))
.RequireAuthorization();
// Public view of another player (profile card + achievement board).
app.MapGet("/api/profile/{id}/public", async (string id, ClaimsPrincipal u, SocialService s) =>
{
var p = await s.GetPublicProfile(Uid(u), id);
return p != null ? Results.Json(p, JsonOpts.Default) : Results.NotFound();
}).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();
app.MapGet("/api/players/suggested", async (ClaimsPrincipal u, SocialService s) =>
Results.Json(await s.Suggested(Uid(u)), JsonOpts.Default)).RequireAuthorization();
app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) =>
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
.RequireAuthorization();
@@ -189,11 +207,12 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status,
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed");
});
// Store in-app purchase (Cafe Bazaar / Myket): the native app sends the purchase
// token; we credit the matching pack. (SKU == packId for now.)
app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabVerifyReq req) =>
// Store in-app purchase (Cafe Bazaar / Myket): the client sends the store purchase
// token; we verify it server-to-server, then credit the matching pack (SKU == packId).
app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabService iab, IabVerifyReq req) =>
{
// TODO: verify req.Token with Cafe Bazaar (Pardakht/Poolakey) or Myket dev API.
var valid = await iab.Verify(req.Store, req.ProductId, req.Token);
if (!valid) return Results.BadRequest(new { ok = false, error = "verification_failed" });
var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.ProductId);
return ok
? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default)
@@ -227,7 +246,9 @@ app.MapGet("/api/friends/requests", async (ClaimsPrincipal u, SocialService s) =
Results.Json(await s.ListRequests(Uid(u)), JsonOpts.Default)).RequireAuthorization();
app.MapPost("/api/friends/add", async (ClaimsPrincipal u, SocialService s, QueryReq r) =>
{
var (ok, fa, en) = await s.AddFriend(Uid(u), r.Query);
var (ok, fa, en) = !string.IsNullOrWhiteSpace(r.UserId)
? await s.AddFriendById(Uid(u), r.UserId!)
: await s.AddFriend(Uid(u), r.Query ?? "");
return Results.Json(new { ok, messageFa = fa, messageEn = en }, JsonOpts.Default);
}).RequireAuthorization();
app.MapPost("/api/friends/accept", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
@@ -263,6 +284,6 @@ record EmailLogin(string Email, string Password, string? Name);
record BuyReq(string PackId);
record ShopBuyReq(string Kind, string Id, int Price);
record IabVerifyReq(string Store, string ProductId, string Token);
record QueryReq(string Query);
record QueryReq(string? Query = null, string? UserId = null);
record IdReq(string Id);
record SendReq(string PeerId, string Text);
@@ -32,3 +32,19 @@ public class ConversationDto
public ChatMessageDto? LastMessage { get; set; }
public int Unread { get; set; }
}
/// <summary>A discoverable player in the social "find friends" hub.</summary>
public class PlayerSummaryDto
{
public string Id { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public int Level { get; set; }
public int Rating { get; set; }
public string Status { get; set; } = "offline";
public string Gender { get; set; } = "";
public string? Title { get; set; }
public bool IsFriend { get; set; }
public bool RequestSent { get; set; }
}
+143 -6
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Hokm.Server.Data;
using Hokm.Server.Game;
@@ -14,6 +15,12 @@ public class SocialService
private readonly GameManager _mgr;
private readonly IHubContext<GameHub> _hub;
/// <summary>Max outgoing friend requests allowed per user within a rolling hour.</summary>
public const int FriendReqLimit = 10;
private static readonly TimeSpan FriendReqWindow = TimeSpan.FromHours(1);
// Process-wide log of each user's recent outgoing-request timestamps (resets on restart).
private static readonly ConcurrentDictionary<string, List<DateTime>> _reqLog = new();
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
{
_db = db;
@@ -21,6 +28,28 @@ public class SocialService
_hub = hub;
}
/// <summary>
/// Records an outgoing friend-request attempt against the rolling-hour cap.
/// Returns false (with the minutes until a slot frees) when over the limit.
/// </summary>
private static bool TryRecordRequest(string uid, out int retryMins)
{
retryMins = 0;
var now = DateTime.UtcNow;
var list = _reqLog.GetOrAdd(uid, _ => new List<DateTime>());
lock (list)
{
list.RemoveAll(t => now - t >= FriendReqWindow);
if (list.Count >= FriendReqLimit)
{
retryMins = Math.Max(1, (int)Math.Ceiling((FriendReqWindow - (now - list[0])).TotalMinutes));
return false;
}
list.Add(now);
return true;
}
}
private async Task<FriendDto> FriendDtoFor(string userId)
{
var row = await _db.Profiles.FindAsync(userId);
@@ -60,21 +89,129 @@ public class SocialService
{
var digits = new string(query.Where(char.IsDigit).ToArray());
var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim());
return await AddFriendById(uid, targetId);
}
/// <summary>Send a friend request to a concrete user id (rate-limited to 10/hour).</summary>
public async Task<(bool ok, string messageFa, string messageEn)> AddFriendById(string uid, string targetId)
{
targetId = targetId.Trim();
var target = await _db.Profiles.FindAsync(targetId);
if (target == null || targetId == uid)
return (false, "کاربر پیدا نشد", "User not found");
if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId))
return (false, "از قبل دوست هستید", "Already friends");
if (!await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
{
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
await _db.SaveChangesAsync();
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
}
// Already pending → idempotent success, doesn't consume the hourly quota.
if (await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
if (!TryRecordRequest(uid, out var mins))
return (false,
$"در هر ساعت حداکثر {FriendReqLimit} درخواست دوستی می‌توانید بفرستید. {mins} دقیقه دیگر تلاش کنید.",
$"You can send at most {FriendReqLimit} friend requests per hour. Try again in {mins} min.");
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
await _db.SaveChangesAsync();
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
}
/* --------------------------- discovery ----------------------------- */
private PlayerSummaryDto ToSummary(ProfileDto p, HashSet<string> friendIds, HashSet<string> sentIds) => new()
{
Id = p.Id,
DisplayName = p.DisplayName,
Avatar = p.Avatar,
AvatarImage = p.AvatarImage,
Level = p.Level,
Rating = p.Rating,
Status = _mgr.IsOnline(p.Id) ? "online" : "offline",
Gender = p.Gender ?? "",
Title = p.Title,
IsFriend = friendIds.Contains(p.Id),
RequestSent = sentIds.Contains(p.Id),
};
/// <summary>Search players by display name (case-insensitive contains).</summary>
public async Task<List<PlayerSummaryDto>> SearchPlayers(string uid, string query)
{
query = (query ?? "").Trim();
if (query.Length == 0) return new();
var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet();
var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet();
var rows = await _db.Profiles.Where(p => p.Id != uid).ToListAsync();
var list = new List<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p?.DisplayName == null) continue;
if (!p.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
list.Add(ToSummary(p, friendIds, sentIds));
if (list.Count >= 20) break;
}
return list;
}
/// <summary>Suggested players to befriend (online-first, excludes existing friends).</summary>
public async Task<List<PlayerSummaryDto>> Suggested(string uid)
{
var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet();
var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet();
var rows = await _db.Profiles.Where(p => p.Id != uid).Take(80).ToListAsync();
var list = new List<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p == null || friendIds.Contains(p.Id)) continue;
list.Add(ToSummary(p, friendIds, sentIds));
}
// Online players first, then by rating.
return list
.OrderByDescending(x => x.Status == "online")
.ThenByDescending(x => x.Rating)
.Take(12)
.ToList();
}
/// <summary>Another player's public profile + achievement board (no private fields).</summary>
public async Task<PublicProfileDto?> GetPublicProfile(string uid, string targetId)
{
targetId = targetId.Trim();
var row = await _db.Profiles.FindAsync(targetId);
if (row == null) return null;
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p == null) return null;
var isFriend = await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId);
var requestSent = await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId);
var isYou = targetId == uid;
// Social links honor the owner's privacy: public → everyone, friends → only
// friends (and the owner), hidden → nobody.
var vis = string.IsNullOrEmpty(p.SocialsVisibility) ? "public" : p.SocialsVisibility;
var canSeeSocials = isYou || vis == "public" || (vis == "friends" && isFriend);
return new PublicProfileDto
{
Id = p.Id,
DisplayName = p.DisplayName,
Avatar = p.Avatar,
AvatarImage = p.AvatarImage,
Plan = p.Plan,
Title = p.Title,
Level = p.Level,
Rating = p.Rating,
Stats = p.Stats,
Achievements = p.Achievements,
Unlocked = p.Unlocked,
CreatedAt = p.CreatedAt,
Gender = p.Gender ?? "",
Socials = canSeeSocials ? p.Socials : null,
IsFriend = isFriend,
IsYou = isYou,
RequestSent = requestSent,
};
}
public async Task Accept(string uid, long requestId)
{
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
+8
View File
@@ -23,5 +23,13 @@
"Sandbox": true,
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
"ClientReturnUrl": "http://localhost:3000"
},
"Iab": {
"PackageName": "com.bargevasat.hokm",
"BazaarClientId": "",
"BazaarClientSecret": "",
"BazaarRefreshToken": "",
"MyketAccessToken": "",
"AllowUnverified": false
}
}