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:
@@ -21,17 +21,52 @@ public class LedgerRow
|
|||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>An undirected friendship, stored as two rows (a→b and b→a).</summary>
|
||||||
|
public class FriendEdgeRow
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public string FriendId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FriendRequestRow
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string FromUserId { get; set; } = "";
|
||||||
|
public string ToUserId { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MessageRow
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string UserId { get; set; } = ""; // sender
|
||||||
|
public string PeerId { get; set; } = ""; // recipient
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public bool ReadByPeer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class AppDbContext : DbContext
|
public class AppDbContext : DbContext
|
||||||
{
|
{
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<ProfileRow> Profiles => Set<ProfileRow>();
|
public DbSet<ProfileRow> Profiles => Set<ProfileRow>();
|
||||||
public DbSet<LedgerRow> Ledger => Set<LedgerRow>();
|
public DbSet<LedgerRow> Ledger => Set<LedgerRow>();
|
||||||
|
public DbSet<FriendEdgeRow> Friends => Set<FriendEdgeRow>();
|
||||||
|
public DbSet<FriendRequestRow> FriendRequests => Set<FriendRequestRow>();
|
||||||
|
public DbSet<MessageRow> Messages => Set<MessageRow>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
b.Entity<ProfileRow>().HasKey(p => p.Id);
|
b.Entity<ProfileRow>().HasKey(p => p.Id);
|
||||||
b.Entity<LedgerRow>().HasKey(l => l.Id);
|
b.Entity<LedgerRow>().HasKey(l => l.Id);
|
||||||
b.Entity<LedgerRow>().HasIndex(l => l.UserId);
|
b.Entity<LedgerRow>().HasIndex(l => l.UserId);
|
||||||
|
b.Entity<FriendEdgeRow>().HasKey(f => f.Id);
|
||||||
|
b.Entity<FriendEdgeRow>().HasIndex(f => f.UserId);
|
||||||
|
b.Entity<FriendRequestRow>().HasKey(r => r.Id);
|
||||||
|
b.Entity<FriendRequestRow>().HasIndex(r => r.ToUserId);
|
||||||
|
b.Entity<MessageRow>().HasKey(m => m.Id);
|
||||||
|
b.Entity<MessageRow>().HasIndex(m => new { m.UserId, m.PeerId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace Hokm.Server.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used by `dotnet ef migrations add ...` to build the context at design time.
|
||||||
|
/// Targets Postgres (prod). Set HOKM_DESIGN_CONN to override the connection.
|
||||||
|
/// dotnet ef migrations add InitialCreate -p src/Hokm.Server
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var conn = Environment.GetEnvironmentVariable("HOKM_DESIGN_CONN")
|
||||||
|
?? "Host=localhost;Database=hokm;Username=postgres;Password=postgres";
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseNpgsql(conn)
|
||||||
|
.Options;
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,18 +141,20 @@ public sealed class GameManager
|
|||||||
public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit);
|
public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit);
|
||||||
public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction);
|
public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction);
|
||||||
|
|
||||||
private int _online;
|
private readonly ConcurrentDictionary<string, int> _onlineUsers = new();
|
||||||
public int OnlineCount => Volatile.Read(ref _online);
|
public int OnlineCount => _onlineUsers.Count;
|
||||||
|
public bool IsOnline(string userId) => _onlineUsers.ContainsKey(userId);
|
||||||
|
|
||||||
public void OnConnected(string userId)
|
public void OnConnected(string userId)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _online);
|
_onlineUsers.AddOrUpdate(userId, 1, (_, n) => n + 1);
|
||||||
RoomOf(userId)?.SetConnected(userId, true);
|
RoomOf(userId)?.SetConnected(userId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnDisconnected(string userId)
|
public void OnDisconnected(string userId)
|
||||||
{
|
{
|
||||||
if (Interlocked.Decrement(ref _online) < 0) Interlocked.Exchange(ref _online, 0);
|
if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
|
||||||
|
_onlineUsers.TryRemove(userId, out _);
|
||||||
CancelMatchmaking(userId);
|
CancelMatchmaking(userId);
|
||||||
RoomOf(userId)?.SetConnected(userId, false);
|
RoomOf(userId)?.SetConnected(userId, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
|
|||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
using Hokm.Server.Payments;
|
using Hokm.Server.Payments;
|
||||||
using Hokm.Server.Profiles;
|
using Hokm.Server.Profiles;
|
||||||
|
using Hokm.Server.Social;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -32,6 +33,7 @@ builder.Services.AddDbContext<AppDbContext>(o =>
|
|||||||
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<ProfileService>();
|
builder.Services.AddScoped<ProfileService>();
|
||||||
|
builder.Services.AddScoped<SocialService>();
|
||||||
|
|
||||||
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
||||||
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
|
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
|
||||||
@@ -90,7 +92,13 @@ builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureCreated();
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
// Use EF migrations once any exist (prod/Postgres); otherwise create the
|
||||||
|
// schema directly (dev/SQLite, or before the first migration is generated).
|
||||||
|
if (db.Database.GetMigrations().Any()) db.Database.Migrate();
|
||||||
|
else db.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
@@ -172,6 +180,17 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status,
|
|||||||
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed");
|
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) =>
|
||||||
|
{
|
||||||
|
// TODO: verify req.Token with Cafe Bazaar (Pardakht/Poolakey) or Myket dev API.
|
||||||
|
var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.ProductId);
|
||||||
|
return ok
|
||||||
|
? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default)
|
||||||
|
: Results.BadRequest(new { ok = false });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
|
app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||||
{
|
{
|
||||||
var (day, last, avail) = await svc.GetDaily(Uid(u));
|
var (day, last, avail) = await svc.GetDaily(Uid(u));
|
||||||
@@ -192,6 +211,39 @@ app.MapPost("/api/shop/buy", async (ClaimsPrincipal u, ProfileService svc, ShopB
|
|||||||
: Results.BadRequest(new { ok = false, error = err });
|
: Results.BadRequest(new { ok = false, error = err });
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// --- friends + chat (server-persisted, real-time over the hub) ---
|
||||||
|
app.MapGet("/api/friends", async (ClaimsPrincipal u, SocialService s) =>
|
||||||
|
Results.Json(await s.ListFriends(Uid(u)), JsonOpts.Default)).RequireAuthorization();
|
||||||
|
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);
|
||||||
|
return Results.Json(new { ok, messageFa = fa, messageEn = en }, JsonOpts.Default);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
app.MapPost("/api/friends/accept", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
|
||||||
|
{
|
||||||
|
if (long.TryParse(r.Id, out var id)) await s.Accept(Uid(u), id);
|
||||||
|
return Results.Ok();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
app.MapPost("/api/friends/decline", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
|
||||||
|
{
|
||||||
|
if (long.TryParse(r.Id, out var id)) await s.Decline(Uid(u), id);
|
||||||
|
return Results.Ok();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
app.MapPost("/api/friends/remove", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
|
||||||
|
{
|
||||||
|
await s.Remove(Uid(u), r.Id);
|
||||||
|
return Results.Ok();
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/chat", async (ClaimsPrincipal u, SocialService s) =>
|
||||||
|
Results.Json(await s.Conversations(Uid(u)), JsonOpts.Default)).RequireAuthorization();
|
||||||
|
app.MapGet("/api/chat/messages", async (ClaimsPrincipal u, SocialService s, string peer) =>
|
||||||
|
Results.Json(await s.Messages(Uid(u), peer), JsonOpts.Default)).RequireAuthorization();
|
||||||
|
app.MapPost("/api/chat/send", async (ClaimsPrincipal u, SocialService s, SendReq r) =>
|
||||||
|
Results.Json(await s.Send(Uid(u), r.PeerId, r.Text), JsonOpts.Default)).RequireAuthorization();
|
||||||
|
|
||||||
app.MapHub<GameHub>("/hub/game");
|
app.MapHub<GameHub>("/hub/game");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -201,3 +253,7 @@ record OtpVerify(string Phone, string Code, string? Name);
|
|||||||
record EmailLogin(string Email, string Password, string? Name);
|
record EmailLogin(string Email, string Password, string? Name);
|
||||||
record BuyReq(string PackId);
|
record BuyReq(string PackId);
|
||||||
record ShopBuyReq(string Kind, string Id, int Price);
|
record ShopBuyReq(string Kind, string Id, int Price);
|
||||||
|
record IabVerifyReq(string Store, string ProductId, string Token);
|
||||||
|
record QueryReq(string Query);
|
||||||
|
record IdReq(string Id);
|
||||||
|
record SendReq(string PeerId, string Text);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Hokm.Server.Social;
|
||||||
|
|
||||||
|
public class FriendDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string Avatar { get; set; } = "a-fox";
|
||||||
|
public int Level { get; set; }
|
||||||
|
public int Rating { get; set; }
|
||||||
|
public string Status { get; set; } = "offline"; // online | offline
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FriendRequestDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public FriendDto From { get; set; } = new();
|
||||||
|
public long CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChatMessageDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public bool FromMe { get; set; }
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
public long Ts { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConversationDto
|
||||||
|
{
|
||||||
|
public FriendDto Friend { get; set; } = new();
|
||||||
|
public ChatMessageDto? LastMessage { get; set; }
|
||||||
|
public int Unread { get; set; }
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production.",
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
|
||||||
|
"Issuer": "hokm",
|
||||||
|
"Audience": "hokm-clients"
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"Provider": "postgres"
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
|
||||||
|
"Default": "Host=db.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true"
|
||||||
|
},
|
||||||
|
"Zarinpal": {
|
||||||
|
"MerchantId": "<your-live-merchant-id>",
|
||||||
|
"Sandbox": false,
|
||||||
|
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
|
||||||
|
"ClientReturnUrl": "https://yourdomain.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ export class SignalrService implements OnlineService {
|
|||||||
private notifCbs = new Set<(n: AppNotification) => void>();
|
private notifCbs = new Set<(n: AppNotification) => void>();
|
||||||
private profileCbs = new Set<(p: UserProfile) => void>();
|
private profileCbs = new Set<(p: UserProfile) => void>();
|
||||||
private rewardCbs = new Set<(r: RewardResult) => void>();
|
private rewardCbs = new Set<(r: RewardResult) => void>();
|
||||||
|
private friendCbs = new Set<(f: Friend[]) => void>();
|
||||||
|
private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
|
||||||
private cachedProfile: UserProfile | null = null;
|
private cachedProfile: UserProfile | null = null;
|
||||||
private mockNotifUnsub?: () => void;
|
private mockNotifUnsub?: () => void;
|
||||||
|
|
||||||
@@ -122,6 +124,23 @@ export class SignalrService implements OnlineService {
|
|||||||
this.profileCbs.forEach((cb) => cb(p));
|
this.profileCbs.forEach((cb) => cb(p));
|
||||||
});
|
});
|
||||||
conn.on("reward", (r: RewardResult) => this.rewardCbs.forEach((cb) => cb(r)));
|
conn.on("reward", (r: RewardResult) => this.rewardCbs.forEach((cb) => cb(r)));
|
||||||
|
conn.on("friendRequest", (from: Friend) => {
|
||||||
|
this.notifCbs.forEach((cb) =>
|
||||||
|
cb({
|
||||||
|
id: `n_fr_${from.id}`,
|
||||||
|
kind: "friend_request",
|
||||||
|
titleFa: "درخواست دوستی جدید",
|
||||||
|
titleEn: "New friend request",
|
||||||
|
bodyFa: `${from.displayName} میخواهد با شما دوست شود`,
|
||||||
|
bodyEn: `${from.displayName} wants to be your friend`,
|
||||||
|
icon: "👥",
|
||||||
|
ts: Date.now(),
|
||||||
|
read: false,
|
||||||
|
} as AppNotification));
|
||||||
|
void this.refreshFriends();
|
||||||
|
});
|
||||||
|
conn.on("social", () => void this.refreshFriends());
|
||||||
|
conn.on("chat", (m: { peerId: string }) => void this.emitChat(m.peerId));
|
||||||
|
|
||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
try {
|
try {
|
||||||
@@ -306,13 +325,33 @@ export class SignalrService implements OnlineService {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
listFriends() { return this.mock.listFriends(); }
|
private async refreshFriends() {
|
||||||
listRequests() { return this.mock.listRequests(); }
|
try {
|
||||||
addFriend(q: string) { return this.mock.addFriend(q); }
|
const f = await this.listFriends();
|
||||||
acceptRequest(id: string) { return this.mock.acceptRequest(id); }
|
this.friendCbs.forEach((cb) => cb(f));
|
||||||
declineRequest(id: string) { return this.mock.declineRequest(id); }
|
} catch {
|
||||||
removeFriend(id: string) { return this.mock.removeFriend(id); }
|
/* ignore */
|
||||||
onFriends(cb: (f: Friend[]) => void) { return this.mock.onFriends(cb); }
|
}
|
||||||
|
}
|
||||||
|
private async emitChat(peerId: string) {
|
||||||
|
try {
|
||||||
|
const m = await this.getMessages(peerId);
|
||||||
|
this.chatCbs.forEach((cb) => cb(peerId, m));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listFriends() { return this.getJson<Friend[]>("/api/friends"); }
|
||||||
|
listRequests() { return this.getJson<FriendRequest[]>("/api/friends/requests"); }
|
||||||
|
addFriend(q: string) {
|
||||||
|
return this.send<{ ok: boolean; messageFa: string; messageEn: string }>(
|
||||||
|
"POST", "/api/friends/add", { query: q });
|
||||||
|
}
|
||||||
|
async acceptRequest(id: string) { await this.send<unknown>("POST", "/api/friends/accept", { id }); }
|
||||||
|
async declineRequest(id: string) { await this.send<unknown>("POST", "/api/friends/decline", { id }); }
|
||||||
|
async removeFriend(id: string) { await this.send<unknown>("POST", "/api/friends/remove", { id }); }
|
||||||
|
onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); }
|
||||||
|
|
||||||
createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
|
createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
|
||||||
setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); }
|
setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); }
|
||||||
@@ -323,11 +362,15 @@ export class SignalrService implements OnlineService {
|
|||||||
leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); }
|
leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); }
|
||||||
onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); }
|
onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); }
|
||||||
|
|
||||||
listConversations(): Promise<Conversation[]> { return this.mock.listConversations(); }
|
listConversations(): Promise<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
|
||||||
getMessages(id: string): Promise<ChatMessage[]> { return this.mock.getMessages(id); }
|
getMessages(id: string): Promise<ChatMessage[]> {
|
||||||
sendMessage(id: string, text: string) { return this.mock.sendMessage(id, text); }
|
return this.getJson<ChatMessage[]>(`/api/chat/messages?peer=${encodeURIComponent(id)}`);
|
||||||
markRead(id: string) { return this.mock.markRead(id); }
|
}
|
||||||
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
|
sendMessage(id: string, text: string) {
|
||||||
|
return this.send<ChatMessage>("POST", "/api/chat/send", { peerId: id, text });
|
||||||
|
}
|
||||||
|
async markRead() { /* server marks read when messages are fetched */ }
|
||||||
|
onChat(cb: (id: string, m: ChatMessage[]) => void) { this.chatCbs.add(cb); return () => this.chatCbs.delete(cb); }
|
||||||
|
|
||||||
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
|
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
|
||||||
this.notifCbs.add(cb);
|
this.notifCbs.add(cb);
|
||||||
|
|||||||
Reference in New Issue
Block a user