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
+55 -12
View File
@@ -53,6 +53,8 @@ export class SignalrService implements OnlineService {
private notifCbs = new Set<(n: AppNotification) => void>();
private profileCbs = new Set<(p: UserProfile) => 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 mockNotifUnsub?: () => void;
@@ -122,6 +124,23 @@ export class SignalrService implements OnlineService {
this.profileCbs.forEach((cb) => cb(p));
});
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;
try {
@@ -306,13 +325,33 @@ export class SignalrService implements OnlineService {
return p;
}
listFriends() { return this.mock.listFriends(); }
listRequests() { return this.mock.listRequests(); }
addFriend(q: string) { return this.mock.addFriend(q); }
acceptRequest(id: string) { return this.mock.acceptRequest(id); }
declineRequest(id: string) { return this.mock.declineRequest(id); }
removeFriend(id: string) { return this.mock.removeFriend(id); }
onFriends(cb: (f: Friend[]) => void) { return this.mock.onFriends(cb); }
private async refreshFriends() {
try {
const f = await this.listFriends();
this.friendCbs.forEach((cb) => cb(f));
} catch {
/* ignore */
}
}
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); }
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); }
onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); }
listConversations(): Promise<Conversation[]> { return this.mock.listConversations(); }
getMessages(id: string): Promise<ChatMessage[]> { return this.mock.getMessages(id); }
sendMessage(id: string, text: string) { return this.mock.sendMessage(id, text); }
markRead(id: string) { return this.mock.markRead(id); }
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
listConversations(): Promise<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
getMessages(id: string): Promise<ChatMessage[]> {
return this.getJson<ChatMessage[]>(`/api/chat/messages?peer=${encodeURIComponent(id)}`);
}
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 {
this.notifCbs.add(cb);