Portal backend: Facebook OAuth, social features, portal comments
- Facebook Login OAuth (meta-web auth service + routes) - Account linking (merge Quest metaId + Facebook facebookId) - User profile updates (bio, isPublic, displayName) - Social endpoints: follow/unfollow, feed (trending/following/recent), likes - Portal comments via WebSocket (subscribe_portal, send_portal_comment) - Prisma migration: Follow, Like models, facebookId/bio/isPublic on User - Provider OAuth source=web redirect support for portal callbacks - Docker compose portal service, CORS multi-origin support
This commit is contained in:
@@ -73,8 +73,16 @@ async function getDecryptedToken(
|
||||
};
|
||||
}
|
||||
|
||||
interface PortalSubscriber {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
socket: WebSocket;
|
||||
}
|
||||
|
||||
export class ChatManager {
|
||||
private sessions = new Map<string, ChatSession>(); // key: `${userId}:${planId}`
|
||||
private portalSubscribers = new Map<string, Set<PortalSubscriber>>(); // key: planId
|
||||
private prisma: PrismaClient;
|
||||
private logger: FastifyBaseLogger;
|
||||
|
||||
@@ -184,6 +192,106 @@ export class ChatManager {
|
||||
});
|
||||
}
|
||||
|
||||
async subscribePortalChat(planId: string, userId: string, socket: WebSocket): Promise<void> {
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
if (!this.portalSubscribers.has(planId)) {
|
||||
this.portalSubscribers.set(planId, new Set());
|
||||
}
|
||||
|
||||
// Remove existing subscription for this user+plan
|
||||
const subs = this.portalSubscribers.get(planId)!;
|
||||
for (const sub of subs) {
|
||||
if (sub.userId === userId) {
|
||||
subs.delete(sub);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
subs.add({
|
||||
userId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
socket,
|
||||
});
|
||||
|
||||
this.logger.info({ planId, userId }, 'Portal chat subscribed');
|
||||
}
|
||||
|
||||
async handlePortalComment(planId: string, userId: string, text: string): Promise<void> {
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
const message = {
|
||||
id: `portal-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
||||
authorName: user.displayName,
|
||||
authorImageUrl: user.avatarUrl,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
isModerator: false,
|
||||
isBroadcaster: false,
|
||||
color: '#00BCD4',
|
||||
};
|
||||
|
||||
// Broadcast to all portal subscribers watching this plan
|
||||
const subs = this.portalSubscribers.get(planId);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
this.sendToSocket(sub.socket, {
|
||||
type: 'chat_message',
|
||||
planId,
|
||||
service: 'PORTAL',
|
||||
destinationId: 'portal',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also send to the plan owner's chat session (so it shows up in their Android app)
|
||||
const plan = await (this.prisma as any).streamPlan.findUnique({ where: { id: planId } });
|
||||
if (plan) {
|
||||
const ownerSession = this.sessions.get(`${plan.userId}:${planId}`);
|
||||
if (ownerSession) {
|
||||
this.sendToSocket(ownerSession.socket, {
|
||||
type: 'chat_message',
|
||||
planId,
|
||||
service: 'PORTAL',
|
||||
destinationId: 'portal',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleLike(planId: string, userId: string): Promise<void> {
|
||||
// Toggle like in DB
|
||||
const existing = await (this.prisma as any).like.findUnique({
|
||||
where: { userId_planId: { userId, planId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await (this.prisma as any).like.delete({ where: { id: existing.id } });
|
||||
} else {
|
||||
await (this.prisma as any).like.create({ data: { userId, planId } });
|
||||
}
|
||||
|
||||
const count = await (this.prisma as any).like.count({ where: { planId } });
|
||||
|
||||
// Broadcast like update to all portal subscribers
|
||||
const subs = this.portalSubscribers.get(planId);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
this.sendToSocket(sub.socket, {
|
||||
type: 'like_update',
|
||||
planId,
|
||||
count,
|
||||
isLiked: sub.userId === userId ? !existing : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopChat(planId: string, userId: string): Promise<void> {
|
||||
const sessionKey = `${userId}:${planId}`;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
@@ -222,6 +330,18 @@ export class ChatManager {
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up portal subscriptions for this socket
|
||||
for (const [planId, subs] of this.portalSubscribers) {
|
||||
for (const sub of subs) {
|
||||
if (sub.socket === socket) {
|
||||
subs.delete(sub);
|
||||
}
|
||||
}
|
||||
if (subs.size === 0) {
|
||||
this.portalSubscribers.delete(planId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async startYouTubeChat(session: ChatSession, dest: any): Promise<void> {
|
||||
|
||||
66
src/services/meta-web-auth.service.ts
Normal file
66
src/services/meta-web-auth.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface FacebookTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface FacebookProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
}
|
||||
|
||||
export async function exchangeFacebookCode(code: string): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.metaWeb.clientId,
|
||||
client_secret: config.metaWeb.clientSecret,
|
||||
redirect_uri: config.metaWeb.redirectUri,
|
||||
code,
|
||||
});
|
||||
|
||||
const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Facebook token exchange failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as FacebookTokenResponse;
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in };
|
||||
}
|
||||
|
||||
export async function fetchFacebookProfile(accessToken: string): Promise<{
|
||||
facebookId: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
}> {
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/v19.0/me?fields=id,name,picture.type(large)&access_token=${accessToken}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Facebook profile fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as FacebookProfile;
|
||||
return {
|
||||
facebookId: data.id,
|
||||
displayName: data.name,
|
||||
email: data.email ?? null,
|
||||
avatarUrl: data.picture?.data?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFacebookOAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.metaWeb.clientId,
|
||||
redirect_uri: config.metaWeb.redirectUri,
|
||||
state,
|
||||
scope: 'public_profile',
|
||||
response_type: 'code',
|
||||
});
|
||||
return `https://www.facebook.com/v19.0/dialog/oauth?${params}`;
|
||||
}
|
||||
Reference in New Issue
Block a user