Server-backed friends, chat, IAB scaffold + EF migrations/Postgres

- Social: EF-backed friends graph + chat (SocialService/SocialModels);
  REST endpoints (friends add/accept/decline/remove/list/requests,
  chat conversations/messages/send) with real-time hub events
  (friendRequest/social/chat). GameManager tracks online users for presence.
- Client SignalrService: friends + chat now hit the server and react to
  hub events (refetch + emit); no longer delegated to the mock.
- IAB: /api/coins/iab/verify endpoint + IabVerifyReq for Cafe Bazaar/Myket
  (token verification is a documented TODO pending store accounts/SKUs).
- Persistence: EF Core Design package + DesignTimeDbContextFactory (Postgres),
  Program auto-migrate/EnsureCreated, appsettings.Production.json.example
  with Supabase connection + live ZarinPal template.

Verified end-to-end (two users, SQLite dev): request -> accept ->
bidirectional friends, chat send with per-user fromMe, unread count +
read-on-fetch. Server + client builds clean (dotnet build, tsc, next build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 18:26:22 +03:30
parent cfed2950b2
commit e778e8b5bd
9 changed files with 381 additions and 17 deletions
@@ -0,0 +1,150 @@
using System.Text.Json;
using Hokm.Server.Data;
using Hokm.Server.Game;
using Hokm.Server.Hubs;
using Hokm.Server.Profiles;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace Hokm.Server.Social;
public class SocialService
{
private readonly AppDbContext _db;
private readonly GameManager _mgr;
private readonly IHubContext<GameHub> _hub;
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
{
_db = db;
_mgr = mgr;
_hub = hub;
}
private async Task<FriendDto> FriendDtoFor(string userId)
{
var row = await _db.Profiles.FindAsync(userId);
var p = row != null ? JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default) : null;
return new FriendDto
{
Id = userId,
Username = p?.Username ?? userId,
DisplayName = p?.DisplayName ?? userId,
Avatar = p?.Avatar ?? "a-fox",
Level = p?.Level ?? 1,
Rating = p?.Rating ?? 1000,
Status = _mgr.IsOnline(userId) ? "online" : "offline",
};
}
/* ----------------------------- friends ----------------------------- */
public async Task<List<FriendDto>> ListFriends(string uid)
{
var ids = await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync();
var list = new List<FriendDto>();
foreach (var id in ids) list.Add(await FriendDtoFor(id));
return list;
}
public async Task<List<FriendRequestDto>> ListRequests(string uid)
{
var reqs = await _db.FriendRequests.Where(r => r.ToUserId == uid).ToListAsync();
var list = new List<FriendRequestDto>();
foreach (var r in reqs)
list.Add(new FriendRequestDto { Id = r.Id.ToString(), From = await FriendDtoFor(r.FromUserId), CreatedAt = new DateTimeOffset(r.CreatedAt).ToUnixTimeMilliseconds() });
return list;
}
public async Task<(bool ok, string messageFa, string messageEn)> AddFriend(string uid, string query)
{
var digits = new string(query.Where(char.IsDigit).ToArray());
var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.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));
}
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
}
public async Task Accept(string uid, long requestId)
{
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
if (req == null) return;
_db.Friends.Add(new FriendEdgeRow { UserId = uid, FriendId = req.FromUserId });
_db.Friends.Add(new FriendEdgeRow { UserId = req.FromUserId, FriendId = uid });
_db.FriendRequests.Remove(req);
await _db.SaveChangesAsync();
await _hub.Clients.User(req.FromUserId).SendAsync("social", "friend-added");
}
public async Task Decline(string uid, long requestId)
{
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
if (req != null) { _db.FriendRequests.Remove(req); await _db.SaveChangesAsync(); }
}
public async Task Remove(string uid, string friendId)
{
var edges = await _db.Friends.Where(f =>
(f.UserId == uid && f.FriendId == friendId) || (f.UserId == friendId && f.FriendId == uid)).ToListAsync();
_db.Friends.RemoveRange(edges);
await _db.SaveChangesAsync();
await _hub.Clients.User(friendId).SendAsync("social", "friend-removed");
}
/* ------------------------------- chat ------------------------------ */
public async Task<List<ConversationDto>> Conversations(string uid)
{
var msgs = await _db.Messages.Where(m => m.UserId == uid || m.PeerId == uid).ToListAsync();
var byPartner = msgs.GroupBy(m => m.UserId == uid ? m.PeerId : m.UserId);
var convs = new List<ConversationDto>();
foreach (var g in byPartner)
{
var last = g.OrderByDescending(m => m.CreatedAt).First();
convs.Add(new ConversationDto
{
Friend = await FriendDtoFor(g.Key),
LastMessage = ToDto(last, uid),
Unread = g.Count(m => m.PeerId == uid && !m.ReadByPeer),
});
}
return convs.OrderByDescending(c => c.LastMessage?.Ts ?? 0).ToList();
}
public async Task<List<ChatMessageDto>> Messages(string uid, string peerId)
{
var msgs = await _db.Messages
.Where(m => (m.UserId == uid && m.PeerId == peerId) || (m.UserId == peerId && m.PeerId == uid))
.OrderBy(m => m.CreatedAt).ToListAsync();
var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList();
if (unread.Count > 0) { unread.ForEach(m => m.ReadByPeer = true); await _db.SaveChangesAsync(); }
return msgs.Select(m => ToDto(m, uid)).ToList();
}
public async Task<ChatMessageDto> Send(string uid, string peerId, string text)
{
var m = new MessageRow { UserId = uid, PeerId = peerId, Text = text.Trim(), CreatedAt = DateTime.UtcNow };
_db.Messages.Add(m);
await _db.SaveChangesAsync();
await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid });
return ToDto(m, uid);
}
private static ChatMessageDto ToDto(MessageRow m, string uid) => new()
{
Id = m.Id.ToString(),
FromMe = m.UserId == uid,
Text = m.Text,
Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
};
}