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:
2026-03-02 12:32:39 +01:00
parent 6931670a1f
commit 7ce1c2a8bc
17 changed files with 1060 additions and 26 deletions

View File

@@ -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> {